flyingsalmon

Drawer

A modal panel at the right edge of the viewport for secondary content that accompanies the page still visible beside it: filters for a list, the detail of a selected row. It is dialog's root and dialog's parts with one panel of its own, so it traps focus, locks scroll, and returns focus to its trigger the way a dialog does.

Drawer or dialog, and never navigation

Pick a drawer when the user benefits from seeing the page while the panel is open. A dialog interrupts: it sits centred and whatever is behind it is irrelevant until it closes. A drawer accompanies: it sits at the edge so its subject — the list being filtered, the row being inspected — stays in view. A confirmation is never a drawer, and neither is a destructive prompt; both stop the page, which is what a dialog is for.

A drawer is never app navigation. Moving between sections of an app belongs to the sidebar at every width, including the strip it collapses to under 700px. A panel that holds the app's nav links is a sidebar under another name.

Parts

Nine parts: Drawer, DrawerTrigger, DrawerContent, DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter, DrawerClose, and the built-in X close button. Everything but DrawerContent is dialog's part re-exported under a Drawer name, so a drawer composes exactly as a dialog does and every prop means the same thing. DrawerTitle is required. DrawerBody is the only scroll region. The X is always rendered, positioned top-right and last in the DOM, because on a narrow screen the panel covers almost everything and there is little scrim left to click.

Drawer takes dialog's root props — open, defaultOpen, onOpenChange, dismissible, and pending — and no size.

Geometry and width

The panel is inset 8px from the top, the bottom, and the right, with a full border, the rounded-xl radius, and the same --popover surface dialog paints, so it reads as a surface on the page rather than as the page's edge. It is 448px wide, capped at the viewport minus 16px, which is what makes it fill a phone screen without a separate phone layout. There is no side prop and no size prop — the edge is the right edge and the width is one number.

fitContent on DrawerContent grows the panel to the width of its body. It never shrinks the panel below 448px and never grows it past the viewport cap. The body content has to set its own width for this to mean anything. A bare paragraph's widest natural layout is its whole text on one line, so a body of loose prose pushes the panel straight to the cap. The title and the description never drive the width; they wrap inside whatever the body sets.

Scroll and footer

The panel is the full viewport height minus its inset, and DrawerBody scrolls inside it while the title and the footer stay in place, so the actions never scroll out of reach. The footer is dialog's: justify-end gap-2 from 640px, full-width and column-reversed below it, with the primary action last in the DOM for the keyboard.

Dismissible and pending

dismissible={false} blocks an outside click only, permanently — Escape and the X still close. pending blocks Escape, outside click, and the X all at once, disables the X, and sets aria-busy on the panel. The drawer shows no other busyness of its own; the button inside carries the spinner. A drawer whose controls apply instantly never sets pending.

Filters for a list

The panel a drawer was designed for. Open it and the list it filters is still on screen beside it.

Light
Dark

The detail of a selected row

One drawer serves every row. The app holds the selected row and drives open, so the panel has no DrawerTrigger; the buttons in the table are plain buttons that happen to open it, and they carry none of the aria-expanded wiring a real trigger sets.

Light
StopNights
Kyoto3
Kanazawa2
Tokyo4
Dark
StopNights
Kyoto3
Kanazawa2
Tokyo4

fitContent for a body that sets its own width

The itinerary table below is 512px wide, so the panel grows to hold it. Drop fitContent and the same table would scroll inside a 448px panel.

Light
Dark

A form that survives a stray click

dismissible={false} on a drawer holding unsaved input. Click the page beside it and the panel stays; Escape and the X still close it.

Light
Dark

An operation running inside

Save sets pending on the drawer while the button carries its own spinner. Escape, the scrim, and the X all do nothing until it finishes.

Light
Dark

Motion

The panel travels 24px from the right with a fade, 250ms on the bounce curve in and 150ms on the settle curve out. It does not slide in from off screen. The bounce curve overshoots by 4%, which on a full 448px travel is 18px past the resting position and visibly widens the 8px inset before it settles. Over 24px the overshoot is about 1px. The scrim animates opacity only, from 0 to 0.5 over a solid neutral-950, so no color in the system carries alpha.

Accessibility

The drawer is a modal dialog. It is labelled by its required title and, when present, described by DrawerDescription, and it carries aria-busy while pending. Focus lands inside on open — on the first body or footer control, never the X — and returns to the trigger on close. Tab cycles inside and ends on the X. Content outside an open drawer is hidden from the accessibility tree. The surface, the text, and the border are dialog's colors, so every pair meets WCAG AA in both modes with nothing new to measure.