Drawer
A gesture-driven panel that slides in from a screen edge.
Installation
Included in poetry-ui — available as
poetry_drawer
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add drawer
Default
<%# The upstream demo: a right-edge drawer picking a delivery slot.
direction: is the DISMISS direction - :right slides in from (and
swipes out to) the right edge. The Cancel button is the DrawerClose
equivalent: any button inside the drawer can close it by targeting
the controller's close action. %>
<% slots = [
["asap", "Standard delivery", "25–35 min · Driver assigned now", "Fastest"],
["17:00", "5:00 PM – 5:15 PM", "Prep starts at 4:45 PM", nil],
["17:30", "5:30 PM – 5:45 PM", "Good if you're heading home", nil],
["18:00", "6:00 PM – 6:15 PM", "Most popular · High demand", nil],
["18:30", "6:30 PM – 6:45 PM", "Last slot before kitchen closes", nil]
] %>
<%= poetry_drawer(direction: :right) do |drawer| %>
<% drawer.with_trigger(variant: :secondary) { "Open drawer" } %>
<% drawer.with_title { "Pick a delivery time" } %>
<% drawer.with_description { "We'll prepare your order as soon as possible." } %>
<% drawer.with_footer do %>
<%= poetry_button { "Confirm delivery time" } %>
<%= poetry_button(variant: :outline, data: { action: "click->poetry--core--drawer#close" }) { "Cancel" } %>
<% end %>
<%= poetry_radio_group(name: "delivery_time", value: "asap", label: "Delivery time", class: "gap-2") do |group| %>
<% slots.each do |value, title, detail, badge| %>
<% group.with_item(
value: value, class: "mt-0.5 self-start",
label: tag.span(class: "grid gap-1 leading-snug") {
safe_join([
tag.span(class: "flex items-center gap-2 text-sm font-medium") {
safe_join([title, badge ? poetry_badge(variant: :secondary) { badge } : nil].compact)
},
tag.span(detail, class: "text-sm text-muted-foreground")
])
}
) %>
<% end %>
<% end %>
<% end %>
Edit form
<%= poetry_drawer(show_swipe_handle: true) do |drawer| %>
<% drawer.with_trigger(variant: :outline) { "Edit account" } %>
<% drawer.with_title { "Edit account" } %>
<% drawer.with_description { "Update your details and save when you're finished." } %>
<% drawer.with_footer do %>
<%= poetry_button(type: :submit) { "Save changes" } %>
<% end %>
<div class="grid gap-4">
<div class="grid gap-2">
<%= poetry_label(for_id: "drawer-account-name") { "Full name" } %>
<%= poetry_input(name: "name", value: "Jordan Ellis", id: "drawer-account-name") %>
</div>
<div class="grid gap-2">
<%= poetry_label(for_id: "drawer-account-email") { "Email" } %>
<%= poetry_input(name: "email", type: "email", value: "jordan@example.com", id: "drawer-account-email") %>
</div>
</div>
<% end %>
Nested
<%= poetry_drawer(show_swipe_handle: true) do |drawer| %>
<% drawer.with_trigger(variant: :outline) { "Manage subscription" } %>
<% drawer.with_title { "Manage subscription" } %>
<% drawer.with_description { "Review your plan, or open billing for the details." } %>
<% drawer.with_footer do %>
<%= poetry_button { "Save changes" } %>
<% end %>
<%= poetry_drawer(show_swipe_handle: true) do |billing| %>
<% billing.with_trigger(variant: :ghost) { "View billing history" } %>
<% billing.with_title { "Billing history" } %>
<% billing.with_description { "Your most recent invoices." } %>
<% billing.with_footer do %>
<%= poetry_button(variant: :outline) { "Back" } %>
<% end %>
Every invoice is available to download for your records.
<% end %>
<% end %>
Non modal
Non-modal drawer
The page behind stays interactive.
<%# modal: false leaves the page interactive while the drawer is open -
no scrim, no focus trap, no scroll lock. There is no backdrop to
click, so pointer dismissal is off by nature; Esc (while focus is
inside), the swipe, and the wired Close button still exit. %>
<%= poetry_drawer(direction: :right, modal: false) do |drawer| %>
<% drawer.with_trigger(variant: :outline) { "Non modal" } %>
<% drawer.with_title { "Non-modal drawer" } %>
<% drawer.with_description { "The page behind stays interactive." } %>
<% drawer.with_footer do %>
<%= poetry_button(data: { action: "click->poetry--core--drawer#close" }) { "Close" } %>
<% end %>
<div class="h-80 w-full rounded-2xl bg-muted"></div>
<% end %>
Position
Saved filters
Swipe left to dismiss.
<%# direction: picks the edge AND the dismiss gesture - a left drawer
slides in from the left and swipes left to close. :down (the default)
and :up are the sheet shapes; :left and :right make edge panels. %>
<%= poetry_drawer(direction: :left) do |drawer| %>
<% drawer.with_trigger(variant: :outline) { "Open left drawer" } %>
<% drawer.with_title { "Saved filters" } %>
<% drawer.with_description { "Swipe left to dismiss." } %>
<div class="grid gap-2 text-sm text-muted-foreground">
<span>Assigned to me</span>
<span>Due this week</span>
<span>High priority</span>
</div>
<% end %>
Responsive
Edit profile
Make changes to your profile here. Click save when you're done.
Edit profile
Make changes to your profile here. Click save when you're done.
<%# The responsive recipe: a Dialog on desktop, a Drawer on mobile -
upstream swaps components with a useMediaQuery hook; server-rendered
poetry renders BOTH and lets the breakpoint pick the visible one
(caller classes land on each component's root wrapper). Resize the
window across md (768px) to see the swap. %>
<%= poetry_dialog(class: "hidden md:block") do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Edit profile" } %>
<% dialog.with_title { "Edit profile" } %>
<% dialog.with_description { "Make changes to your profile here. Click save when you're done." } %>
<% dialog.with_footer do %>
<%= poetry_button { "Save changes" } %>
<% end %>
<div class="grid gap-4">
<div class="grid gap-2">
<%= poetry_label(for_id: "responsive-dialog-email") { "Email" } %>
<%= poetry_input(name: "email", type: "email", value: "jordan@example.com", id: "responsive-dialog-email") %>
</div>
<div class="grid gap-2">
<%= poetry_label(for_id: "responsive-dialog-username") { "Username" } %>
<%= poetry_input(name: "username", value: "@jordan", id: "responsive-dialog-username") %>
</div>
</div>
<% end %>
<%= poetry_drawer(class: "md:hidden") do |drawer| %>
<% drawer.with_trigger(variant: :outline) { "Edit profile" } %>
<% drawer.with_title { "Edit profile" } %>
<% drawer.with_description { "Make changes to your profile here. Click save when you're done." } %>
<% drawer.with_footer do %>
<%= poetry_button { "Save changes" } %>
<%= poetry_button(variant: :outline, data: { action: "click->poetry--core--drawer#close" }) { "Cancel" } %>
<% end %>
<div class="grid gap-4">
<div class="grid gap-2">
<%= poetry_label(for_id: "responsive-drawer-email") { "Email" } %>
<%= poetry_input(name: "email", type: "email", value: "jordan@example.com", id: "responsive-drawer-email") %>
</div>
<div class="grid gap-2">
<%= poetry_label(for_id: "responsive-drawer-username") { "Username" } %>
<%= poetry_input(name: "username", value: "@jordan", id: "responsive-drawer-username") %>
</div>
</div>
<% end %>
Snap points
Snap points
Drag the drawer to snap between a compact peek and a near full-height view.
<%# snap_points: preset resting heights for a bottom sheet, ascending -
fractions of the full height or px/rem lengths. It opens at the
first point (the compact peek); drag up to snap fuller, drag below
the first point to dismiss. A flick steps one point in its
direction. %>
<%= poetry_drawer(show_swipe_handle: true, snap_points: ["31rem", 1]) do |drawer| %>
<% drawer.with_trigger(variant: :outline) { "Open snap drawer" } %>
<% drawer.with_title { "Snap points" } %>
<% drawer.with_description { "Drag the drawer to snap between a compact peek and a near full-height view." } %>
<% drawer.with_footer do %>
<%= poetry_button(data: { action: "click->poetry--core--drawer#close" }) { "Close" } %>
<% end %>
<div class="h-80 w-full rounded-2xl bg-muted"></div>
<% end %>
Swipe handle
Drawer
Drawer with a swipe handle.
<%# show_swipe_handle: true renders the grab pill - use it on bottom
sheets so the gesture is discoverable. A drag may always start on
the pill, handle or not. The Close button targets the controller's
close action (the DrawerClose equivalent). %>
<%= poetry_drawer(show_swipe_handle: true) do |drawer| %>
<% drawer.with_trigger(variant: :secondary) { "Open bottom drawer" } %>
<% drawer.with_title { "Drawer" } %>
<% drawer.with_description { "Drawer with a swipe handle." } %>
<% drawer.with_footer do %>
<%= poetry_button(data: { action: "click->poetry--core--drawer#close" }) { "Close" } %>
<% end %>
<div class="h-80 w-full rounded-2xl bg-muted"></div>
<% end %>
API
Poetry::Ui::Drawer::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| direction: | Symbol | one of: down, up, left, right; defaults to :down |
The dismiss direction - :down is the mobile bottom sheet; the edge chrome and swipe axis derive from it. |
| modal: | Boolean | defaults to true |
Non-modal (false) opens with show() - no top layer, no scrim, no focus trap, no scroll lock; the page behind stays interactive. Esc (while focus is inside), the swipe, and any wired close button still exit; there is no backdrop to click, so pointer dismissal is off by nature. |
| show_swipe_handle: | Boolean | defaults to false |
Renders the grab pill so the swipe gesture is discoverable. |
| snap_points: | Object | Preset resting heights for a bottom sheet, ascending: fractions of the full height (0..1] or CSS px/rem lengths ([\"31rem\", 1]). The popup runs full-height and opens at the first point; drags move between points, below the first dismisses. direction: :down only. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=drawer] | Root wrapper around the trigger and the <dialog> element |
| [data-slot=drawer-content] | The <dialog> popup - the edge chrome, presence animation, and the swipe contract all ride here (::backdrop inherits the swipe vars, so the overlay fade rides along) |
| [data-slot=drawer-swipe-handle] | The grab pill (show_swipe_handle: true, aria-hidden) - a drag may always start on it |
| [data-slot=drawer-header] | Title block at the top of the popup |
| [data-slot=drawer-title] | The heading - the drawer's accessible name (required slot) |
| [data-slot=drawer-description] | Muted copy under the title, wired to aria-describedby |
| [data-slot=drawer-body] | The scrollable content region between header and footer |
| [data-slot=drawer-footer] | Action row pinned to the bottom of the popup |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| drawer-content | data-open | popup is open (the controller flips the pair at runtime) | — |
| drawer-content | data-closed | popup is closed or animating out (the server-rendered state) | — |
| drawer-content | data-swipe-direction | always - the dismiss direction | down · up · left · right |
| drawer-content | data-swiping | a pointer drag is tracking (transitions go duration-0 - the drawer follows the finger) | — |
| drawer-content | data-snap-points | snap_points: present - the popup runs full-height and --drawer-snap-point-offset rests it at the current point | — |
| drawer-content | data-starting-style | the enter transition's first frame (the presence helper's two-frame trick) | — |
| drawer-content | data-ending-style | held through the exit transition before the native close() | — |
CSS variables
| Part | Variable | Description |
|---|---|---|
| drawer-content | --drawer-swipe-movement-x | px dragged toward a left/right dismissal (controller-written during swipes) |
| drawer-content | --drawer-swipe-movement-y | px dragged toward an up/down dismissal (controller-written during swipes) |
| drawer-content | --drawer-swipe-progress | 0..1 fraction of the dismiss travel (the backdrop fade rides it) |
| drawer-content | --drawer-swipe-strength | remaining-travel factor set on release - scales the exit duration so a mostly-swiped drawer closes fast |
Agent tools
What an agent may do to a rendered instance once the call opts in
(webmcp: "name") — declared beside the wiring, projected
to the registry, registered with the browser's document.modelContext
by poetry-agent.
| Tool | Parameters | Does | Dispatches |
|---|---|---|---|
| open mutating | — | Open the dialog. | poetry--core--drawer#open |
| close mutating | — | Close the dialog. | poetry--core--drawer#close |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--drawer | registers · value dismissible · value direction · value modal · value snap_points (if) |
| content | poetry--core--drawer | close on cancel · backdropClose on click · escapeClose on keydown (unless modal) · swipeStart on pointerdown · swipeMove on pointermove · swipeEnd on pointerup · swipeCancel on pointercancel · target dialog |
| trigger | poetry--core--drawer | open |