Drawer
A drawer is a Headless UI Dialog whose panel slides in from an edge of
the viewport. It shares the Dialog contract (open /
onOpenChange, focus trap, scroll lock, backdrop) and adds side, size and
the layout parts. The panel is portalled to <body> and renders nothing
on the server.
import Drawer from '@zuilib/primitives/drawer'
Basic
Drawer renders the backdrop; you place Drawer.Panel inside it and the
header, body and footer parts inside that. Drawer.Close with no children
is an icon button pinned to the header's top-right corner.
Sides
side picks the edge — logical start / end (following the writing
direction) or physical top / bottom; the slide transition and the
panel's border follow it. The parts read side from context, so nothing
else changes.
Sizes
size is the panel's width for start / end and its height for
top / bottom, read from --drawer-size-sm/md/lg (20, 28 and 40rem by
default). full covers the viewport.
Below sm (40rem) a start / end drawer is full-width whatever its
size, a top / bottom one is at most 85dvh, and the panel pads past
env(safe-area-inset-*).
Non-dismissible
dismissible={false} swallows Escape and backdrop clicks. The drawer then
closes only through Drawer.Close, which always requests the close, or by
flipping open yourself.
Props
DrawerProps extends Headless UI DialogProps (initialFocus, role,
autoFocus, …) with:
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled visibility; use defaultOpen for uncontrolled |
defaultOpen | boolean | false | Uncontrolled initial visibility |
onOpenChange | (open: boolean) => void | — | Called with false from Drawer.Close, and on Escape / backdrop click while dismissible. A controlled drawer never closes on its own; flip open in response |
side | 'start' | 'end' | 'top' | 'bottom' | 'end' | The edge the panel is pinned to and slides from; start / end follow the writing direction |
size | 'sm' | 'md' | 'lg' | 'full' | 'md' | Width (start / end) or height (top / bottom) from --drawer-size-*; full covers the viewport |
dismissible | boolean | true | false ignores Escape and backdrop clicks |
className | string | — | Merged last onto the root Dialog element |
backdropClassName | string | — | Merged last onto the backdrop |
children | ReactNode | — | Drawer.Panel and anything else inside the dialog |
Parts
Each part is also a named export (DrawerPanel, DrawerTitle, …) and
takes className, merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
Drawer.Panel | DialogPanel props | — | The sheet. Fixed to side, sized by size, slides while closed. Headless UI traps focus inside it |
Drawer.Title | DialogTitle props | — | An <h2> the dialog is labelled by |
Drawer.Description | Description props | — | A <p> the dialog is described by |
Drawer.Header | div props | — | Relative box with end padding reserved for the pinned Drawer.Close |
Drawer.Body | div props | — | The scrolling middle |
Drawer.Footer | div props | — | Actions: stacked (primary on top) on narrow screens, end-aligned row from sm up |
Drawer.Close | Button props + label?: string + children? | label: 'Close' | Requests the close even when not dismissible. No children: a 32px icon button pinned top-right. With children: a wrapper button with only the focus ring, so wrap a Button |
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="drawer"] | div | Root. data-side, data-size, data-state="open" | "closed", data-dismissible (present while dismissible) |
[data-slot="drawer-backdrop"] | div | Fixed scrim, foreground at 50%, fades |
[data-slot="drawer-panel"] | div | The sheet. data-side, data-size, plus Headless UI data-closed during transitions |
[data-slot="drawer-header"] | div | — |
[data-slot="drawer-title"] | h2 | — |
[data-slot="drawer-description"] | p | — |
[data-slot="drawer-body"] | div | — |
[data-slot="drawer-footer"] | div | — |
[data-slot="drawer-close"] | button | — |
[data-slot="drawer-close-icon"] | svg | The default X, only without children |
/* Consumer CSS: wider drawers everywhere, and no border on the sheet */
:root {
--drawer-size-md: 32rem;
}
[data-slot="drawer-panel"] {
border: 0;
}
Tokens
| Token | Used for |
|---|---|
--drawer-size-sm/md/lg | Panel extent per size |
--background, --foreground | Panel surface and text |
--border | Panel edge, header and footer dividers |
--shadow-4 | Panel shadow |
--foreground at 50% | Backdrop |
--muted-foreground, --accent, --accent-foreground | Icon close button and its hover |
--ring | Keyboard focus ring |
--duration-slow | Slide and fade |
Accessibility
- Renders a Headless UI
Dialog:role="dialog",aria-modal="true", focus trapped inside the panel and returned to the opener on close, page scroll locked, the rest of the pageinert. Drawer.TitleandDrawer.Descriptionwirearia-labelledbyandaria-describedbyon the dialog; include a title.- Escape closes while
dismissible; a backdrop click too. Withdismissible={false}provide a visibleDrawer.Close. - The icon
Drawer.Closeis a 32px target with a visually hidden name (label, default "Close" fromLabels.close). - The root carries
print:hidden, so an open drawer never prints. - The slide and fade transitions run through Headless UI's
transition; the panel and backdrop are removed from the DOM when closed. - Because the panel is portalled, subtree token overrides around the opener do not reach it; retheme on
:rootor the.darkclass.