Drawer

A slide-in panel anchored to any edge of the viewport — supports right, left, top, and bottom sides with animated open/close transitions.

Installation

bin/rails g shadcnrb:component drawer

Usage

The block is the drawer panel; drawer.trigger marks what opens it — the same shape as sui.dialog. Child parts (drawer.header, …) live on the proxy.

Drawer

Side panel content goes here.

Body content.
<%= sui.drawer do |drawer| %>
  <%= drawer.trigger { sui.button "Open drawer", variant: :outline } %>
  <%= drawer.header do %>
    <%= drawer.title "Drawer" %>
    <%= drawer.description "Side panel content goes here." %>
  <% end %>
  <div class="p-4 text-sm">Body content.</div>
<% end %>

Sides

Pass side: to sui.drawer — one of :right (default), :left, :top, :bottom.

Right drawer

Slides in from the right.

Content panel.

Left drawer

Slides in from the left.

Content panel.

Top drawer

Slides down from the top.

Content panel.

Bottom drawer

Slides up from the bottom.

Content panel.
<div class="flex flex-wrap gap-2">
  <%= sui.drawer do |drawer| %>
    <%= drawer.trigger { sui.button "Right (default)", variant: :outline } %>
    <%= drawer.header do %>
      <%= drawer.title "Right drawer" %>
      <%= drawer.description "Slides in from the right." %>
    <% end %>
    <div class="p-4 text-sm">Content panel.</div>
  <% end %>

  <%= sui.drawer side: :left do |drawer| %>
    <%= drawer.trigger { sui.button "Left", variant: :outline } %>
    <%= drawer.header do %>
      <%= drawer.title "Left drawer" %>
      <%= drawer.description "Slides in from the left." %>
    <% end %>
    <div class="p-4 text-sm">Content panel.</div>
  <% end %>

  <%= sui.drawer side: :top do |drawer| %>
    <%= drawer.trigger { sui.button "Top", variant: :outline } %>
    <%= drawer.header do %>
      <%= drawer.title "Top drawer" %>
      <%= drawer.description "Slides down from the top." %>
    <% end %>
    <div class="p-4 text-sm">Content panel.</div>
  <% end %>

  <%= sui.drawer side: :bottom do |drawer| %>
    <%= drawer.trigger { sui.button "Bottom", variant: :outline } %>
    <%= drawer.header do %>
      <%= drawer.title "Bottom drawer" %>
      <%= drawer.description "Slides up from the bottom." %>
    <% end %>
    <div class="p-4 text-sm">Content panel.</div>
  <% end %>
</div>

Settings drawer

A practical example with a header, scrollable body, and sticky footer.

Settings

Manage your account preferences.

<%= sui.drawer do |drawer| %>
  <%= drawer.trigger { sui.button "Settings", variant: :outline } %>
  <%= drawer.header do %>
    <%= drawer.title "Settings" %>
    <%= drawer.description "Manage your account preferences." %>
  <% end %>
  <div class="flex-1 overflow-y-auto p-4 space-y-4">
    <div class="flex items-center justify-between">
      <%= sui.label "Dark mode" %>
      <%= sui.switch name: "dark_mode" %>
    </div>
    <div class="flex items-center justify-between">
      <%= sui.label "Notifications" %>
      <%= sui.switch name: "notifications", checked: true %>
    </div>
  </div>
  <%= drawer.footer do %>
    <%= sui.button "Save changes" %>
  <% end %>
<% end %>

Inset sheet

A floating sheet with a gap around it is just classes on the panel. The sides already anchor it with inset-y-0, so a margin insets it; h-auto lets the top and bottom insets set the height instead of h-full, and rounded-xl border closes the frame on the side that no longer touches the edge.

Inset sheet

Floats with a gap on every side.

<%= sui.drawer content: { class: "m-4 h-auto rounded-xl border translate-x-[calc(100%+1rem)]" } do |drawer| %>
  <%= drawer.trigger { sui.button "Inset sheet", variant: :outline } %>
  <%= drawer.header do %>
    <%= drawer.title "Inset sheet" %>
    <%= drawer.description "Floats with a gap on every side." %>
  <% end %>
<% end %>

Sizing

Left and right sheets are w-3/4 max-w-sm; top and bottom already take their height from the content. Classes on content: replace those defaults, so w-fit gives a sheet as wide as what is in it.

<%= sui.drawer content: { class: "w-fit" } do |drawer| %>
  <%= drawer.trigger { sui.button "Content-sized", variant: :outline } %>
  <%= drawer.header do %>
    <%= drawer.title "Jump to" %>
  <% end %>
  <nav class="flex flex-col gap-1 p-4 pt-0">
    <% %w[Dashboard Reports Settings].each do |item| %>
      <%= sui.link_to item, "#", variant: :ghost, class: "justify-start" %>
    <% end %>
  </nav>
<% end %>

For a width that follows the page, set the fractions per breakpoint and lift the default cap with max-w-none.

Responsive sheet

Full width on a phone, a third of the page on a wide screen.

<%= sui.drawer content: { class: "w-full max-w-none sm:w-2/3 lg:w-1/2 xl:w-1/3" } do |drawer| %>
  <%= drawer.trigger { sui.button "Responsive width", variant: :outline } %>
  <%= drawer.header do %>
    <%= drawer.title "Responsive sheet" %>
    <%= drawer.description "Full width on a phone, a third of the page on a wide screen." %>
  <% end %>
<% end %>

A bottom sheet already spans the viewport, so a max width plus sm:mx-auto turns it into a centred panel on larger screens — one component, phone and desktop layouts.

Share

Anyone with the link can view this page.

<%= sui.drawer side: :bottom, content: { class: "sm:max-w-md sm:mx-auto sm:rounded-t-xl sm:border-x" } do |drawer| %>
  <%= drawer.trigger { sui.button "Bottom sheet", variant: :outline } %>
  <%= drawer.header do %>
    <%= drawer.title "Share" %>
    <%= drawer.description "Anyone with the link can view this page." %>
  <% end %>
  <%= drawer.footer do %>
    <%= drawer.close "Done" %>
  <% end %>
<% end %>

Lazy loading and triggers

Drawer takes the same lazy-loading and trigger options as sui.dialog: src: / reload: / loading: fetch the panel via a Turbo Frame, the drawer: kwarg on sui.button takes a Hash to wrap the button or a String id to open a drawer from anywhere in the page.

Loading...

<%= sui.button "Lazy drawer", variant: :outline,
      drawer: { src: lazy_hover_card_content_path,
                loading: sui.skeleton(class: "h-24") } %>

<%= sui.button "Detached trigger", variant: :outline, drawer: "detached-demo-drawer" %>
<%= sui.drawer src: lazy_hover_card_content_path, side: :left, id: "detached-demo-drawer" %>

API

MethodArgsDefaultDescription
sui.drawerside:, src:, reload:, loading:, content:, **opts:rightWrapper; the block body is the panel (backdrop + edge-anchored sheet). side: is :right :left :top :bottom; content: is the panel's option hash. src: lazy-loads the panel via Turbo Frame (body or loading: = loading state)
sui.drawer_proxy——Returns a bare proxy for lazy-loaded partials rendered inside a Turbo Frame
drawer.trigger&block—Marks which part of the block is the trigger; your markup, verbatim — click-to-open is bound on the wrapper. Mark one element data-slot="drawer-trigger" to scope it in composite triggers
drawer.closename, **opts—Wraps content so clicking it closes the drawer (use in footer)
drawer.header**opts—Padded flex column for title and description
drawer.footer**opts—Sticky bottom area with padding for action buttons
drawer.titlenamenilSemibold heading rendered as h2
drawer.descriptionnamenilMuted supporting text rendered as p