Overview
<Drawer /> is the canonical edge-anchored sliding panel primitive — Modal's cousin. Where
Modal anchors at the viewport center, Drawer anchors at one of four edges (left / right /
top / bottom) and slides in from there. Everything else is reused verbatim from Modal:
- The same compound API (
Drawer.Trigger/Drawer.Content/Drawer.Header/Drawer.Body/Drawer.Footer/Drawer.Close). - The same lifecycle hooks (
useEscapeStack,useFocusTrap,useScrollLock,<Portal>). - The same accessibility shape (
role="dialog",aria-modal="true",aria-labelledby,aria-describedby). - The same controlled / uncontrolled state model.
What's different lives on <Drawer.Content>: a side prop instead of placement, and a
direction-aware size axis (width on horizontal sides, height on vertical sides).
Overview — trigger, header, body, and footer
When to use
- Mobile navigation menus (
side="left",size="sm"). - Filter / inspector panels next to the main content (
side="right"). - Bottom sheets — share / select / sort actions on mobile (
side="bottom"). - Announcement banners that warrant a dismissable overlay (
side="top"). - Anywhere you'd reach for a Modal but the content is page-related, not page-blocking.
When NOT to use
- The content is purely a hint with no interactivity →
<Tooltip />. - The content is a small interactive panel anchored to a trigger →
<Popover />. - The content is a destructive confirmation centered on the page →
<Modal />. - The content is a list of actions on a button →
<Menu />. - The content is a transient acknowledgement →
<Toast />. - You want a persistent side panel that doesn't darken the page → that's a
<SideNav />pattern (separate component, not yet shipped).
Anatomy
<Drawer>
<Drawer.Trigger> button </Drawer.Trigger>
<Drawer.Content side="right">
<Drawer.Close /> (optional × button in the top-end corner)
<Drawer.Header
title="…" (wired into aria-labelledby)
description="…" (wired into aria-describedby)
avatar={…}
action={…}
/>
<Drawer.Body>… scrollable region …</Drawer.Body>
<Drawer.Footer align="end">
<Drawer.Close asChild><Button variant="ghost">Cancel</Button></Drawer.Close>
<Button>Save</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer><Drawer>
<Drawer.Trigger> button </Drawer.Trigger>
<Drawer.Content side="right">
<Drawer.Close /> (optional × button in the top-end corner)
<Drawer.Header
title="…" (wired into aria-labelledby)
description="…" (wired into aria-describedby)
avatar={…}
action={…}
/>
<Drawer.Body>… scrollable region …</Drawer.Body>
<Drawer.Footer align="end">
<Drawer.Close asChild><Button variant="ghost">Cancel</Button></Drawer.Close>
<Button>Save</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>Drawer— context provider. Ownsopen, the trigger / content refs, the lifecycle hooks (useEscapeStack,useScrollLock), and the ARIA ids.Drawer.Trigger— clones a single child (asChild, default), attaches click + ARIA. Or renders an inline<button>whenasChild={false}. Optional — controlledDrawers can omit it.Drawer.Content— the portal-rendered, focus-trapped, edge-anchored, sliding panel. Carries the visual axes (side/size/overlay).Drawer.Header— title + description + optional avatar + action slots. Auto-wires ARIA via the parent context.Drawer.Body— scrollable middle region (flex-1 min-h-0 overflow-y-auto).Drawer.Footer— button-row withalignvariant (start/center/end/between).Drawer.Close— built-in × button (asChild-able) that callssetOpen(false).
Sides & sizes
The size axis is direction-aware:
| Side | What size controls | Default | Example tokens |
|---|---|---|---|
left | Drawer width | md | sm → max-w-xs … xl → max-w-xl … full → 100% |
right | Drawer width | — | (same as left) |
top | Drawer height | — | sm → max-h-[20rem] … xl → max-h-[36rem] … full → 100% |
bottom | Drawer height | — | (same as top) |
The rems for vertical sides are picked to mirror the rem-equivalents of the horizontal max-w-*
tokens, so a lg left-drawer and a lg top-drawer occupy the same visual budget along their
respective axes.
side and size both accept ResponsiveValue<>, which is the headline pattern Drawer enables:
<Drawer.Content
side={{ base: 'bottom', md: 'right' }}
size={{ base: 'lg', md: 'md' }}
>
…
</Drawer.Content><Drawer.Content
side={{ base: 'bottom', md: 'right' }}
size={{ base: 'lg', md: 'md' }}
>
…
</Drawer.Content>A bottom-sheet on mobile flips to a right-drawer on desktop without any conditional rendering.
Behavior
| Prop | Default | Effect |
|---|---|---|
closeOnEscape | true | Esc closes the topmost Drawer (escape-stack ordering). |
closeOnBackdropClick | true | Click on the backdrop (not inside Content) closes the Drawer. |
trapFocus | true | Focus moves into Content on open; Tab cycles inside; focus returns to trigger on close. |
preventScroll | true | Locks document.body scroll while open via the engine's reference-counted useScrollLock. |
initialFocus | (first focusable) | Element to focus on open. Defaults to the first focusable inside Content; falls back to Content itself (tabIndex={-1}). |
finalFocus | (the trigger) | Element to focus on close. Defaults to whatever opened the Drawer. |
Accessibility
- Trigger carries
aria-haspopup="dialog",aria-expanded,data-state="open"|"closed". - Content carries
role="dialog",aria-modal="true",aria-labelledby={titleId},aria-describedby={descId}— both ids resolve to nodes rendered by<Drawer.Header>'stitleanddescriptionprops automatically. - Focus management (when
trapFocus={true}):- On open: focus moves to
initialFocusif provided, else first focusable child, else Content itself (which carriestabIndex={-1}). - Tab cycles inside Content via the engine's
useFocusTrap. - On close: focus returns to the trigger (or
finalFocusif provided).
- On open: focus moves to
- Escape closes via the engine's
useEscapeStack— only the topmost Drawer closes per press. - Body scroll is locked via
useScrollLockwhile open, restored on close. Reference-counted with Modal's lock (Drawer-over-Modal collapses to a single lock + unlock pair). - Backdrop click uses an
e.target === e.currentTargetsentinel — clicks inside Content (inputs, buttons) bubble up but never satisfy the sentinel, so they never close the Drawer. - Close button carries
aria-label="Close"by default. - Side semantics:
sideis physical (left/right/top/bottom) — RTL does not flip it, because the spatial intuition is what consumers reason about. The inner border uses logical axes (border-s/border-e) so the visual border still lands on the correct edge in RTL. - axe-core: zero violations across the side × size × open-state matrix.
Examples
Basic
Sides
Sizes
Overlays
NavigationDrawer
BottomSheet
ScrollableBody
FormInside
NestedDrawer
ResponsiveSide
Controlled
Programmatic
Theming
defineTheme({
components: {
Drawer: {
defaultProps: { /* root behavior props — closeOnEscape / preventScroll / etc. */ },
styleOverrides: {
backdrop: 'bg-fg-default/50',
content: 'shadow-2xl',
header: '',
body: '',
footer: '',
close: 'text-fg-muted',
},
},
},
});defineTheme({
components: {
Drawer: {
defaultProps: { /* root behavior props — closeOnEscape / preventScroll / etc. */ },
styleOverrides: {
backdrop: 'bg-fg-default/50',
content: 'shadow-2xl',
header: '',
body: '',
footer: '',
close: 'text-fg-muted',
},
},
},
});Per-instance overrides via <Drawer.Content className sx style /> (and the equivalent on every
subpart) merge on top of the theme overrides, which merge on top of the recipe.