const [isOpen, setIsOpen] = useState(false);
<>
<Button label="Open drawer" onClick={() => setIsOpen(true)} />
<Drawer
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Details"
hasScrim={false}
width={360}>
<Section padding={4}>
<VStack gap={2}>
<Heading level={3}>Details</Heading>
<Text type="body">The page behind stays interactive.</Text>
<Button
label="Close"
variant="secondary"
onClick={() => setIsOpen(false)}
/>
</VStack>
</Section>
</Drawer>
</>
// Non-modal: no scrim, no focus trap. In a real master-detail flow, derive
// the open state from the selection — isOpen={selected != null} — and clear
// the selection in onOpenChange.Drawer
Side panel that floats above page content, using the native <dialog> element. Slides in from the inline start or end edge; full height, never reflows the layout underneath.
Authored source examples
Source examples recorded in the documentation for this entry.
const [isOpen, setIsOpen] = useState(false);
<Drawer
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Navigation"
side="start">
<NavPanel />
</Drawer>
// 'start' is left in LTR and mirrors under RTL; 'end' (the default) is the
// inspector convention.const [isOpen, setIsOpen] = useState(false);
<Drawer
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Details"
width="50%">
<DetailsPanel />
</Drawer>
// A number is pixels (width={320}); a string is any CSS length ('32rem',
// '50%'). The budget also caps the drawer on mobile.const [isOpen, setIsOpen] = useState(false);
<Drawer
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Filters"
width={360}>
<FilterControls />
</Drawer>
// Below 640px the panel preserves a 56px reveal of the page behind (still
// capped by width), so the drawer reads as an overlay, not a navigation.const [isOpen, setIsOpen] = useState(false);
<Drawer
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Filters"
width={560}
isFullWidthOnMobile>
<FilterControls />
</Drawer>const [openModal, setOpenModal] = useState(false);
const [openPanel, setOpenPanel] = useState(false);
<>
{/* Modal (default): the scrim dims the page, focus is trapped, and a
scrim click closes. */}
<Drawer isOpen={openModal} onOpenChange={setOpenModal} label="Edit details">
<EditForm />
</Drawer>
{/* Non-modal: no scrim, no focus trap; the page behind stays interactive. */}
<Drawer
isOpen={openPanel}
onOpenChange={setOpenPanel}
label="Details"
hasScrim={false}>
<DetailsPanel />
</Drawer>
</>const [order, setOrder] = useState(null);
const [lineItem, setLineItem] = useState(null);
<>
<Drawer
isOpen={order != null}
onOpenChange={isOpen => !isOpen && setOrder(null)}
label="Order details"
hasScrim={false}>
<OrderDetails order={order} onSelectLineItem={setLineItem} />
</Drawer>
<Drawer
isOpen={lineItem != null}
onOpenChange={isOpen => !isOpen && setLineItem(null)}
label="Line item"
hasScrim={false}>
<LineItemDetails item={lineItem} />
</Drawer>
</>
// Last-opened stacks on top; Escape closes the line item first.Usage
A side panel that floats above page content for inspectors and detail views: the "click a table row, see its details" pattern. Unlike a docked panel it overlays the layout instead of reflowing it. Works on desktop and touch: the width budget applies on desktop and the panel preserves a 56px page reveal below 640px without exceeding the width budget. Escape closes the drawer and focus returns to the element that opened it. Entry/exit slide animation respects prefers-reduced-motion. Stacking contract: sibling drawers stack last-opened on top, Escape closes only the topmost, and closing peels innermost-first; render them as siblings, never nested.
The content area is full-bleed: compose your own header, body, and footer. A plain recipe is all a drawer needs — Section padding={4} wrapping a VStack with a Heading, body content, and trailing actions; there is no DrawerHeader sub-component. The built-in close button floats above the content in the top-trailing corner, so leave it clearance in a custom header row, or pass hasCloseButton={false} when the content provides its own dismissal. Put data-autofocus on the control that should receive focus on open.
Choosing a surface: use Dialog for a centered decision or short form, Drawer for full-height side detail that keeps the page in sight, BottomSheet for block-axis sheets on touch, and a docked panel (a layout column) when content should reflow the page instead of floating over it.
Theming: the panel is the single stable target (astryx-drawer, with data-side reflecting side). The scrim is the panel's native ::backdrop, and the built-in close button is a ghost Button reachable through the astryx-button target.
- Use for contextual detail views (row inspectors, entity details) where the user should keep the underlying list in sight.
- Keep the caller as the source of truth: derive isOpen from selection state and clear the selection in onOpenChange.
- Use hasScrim={false} for master-detail flows; non-modal drawers do not trap focus and the page behind stays interactive.
- Keep the last-selected item rendered on close: children stay mounted during the exit animation, so nulling content mid-close blanks the panel while it slides out.
- Use a Drawer for short confirmations or small forms; use Dialog or AlertDialog instead.
- Reach for a Drawer when the content should push the page aside; a Drawer floats over content, so use a docked panel or layout column instead.
- Use a Drawer as a bottom or top sheet; it is inline-axis only, so use BottomSheet for block-axis sheets.
- Nest a Drawer inside another Drawer; render drawers as siblings; the last-opened stacks on top and Escape closes it first.
Typed props
| Prop | Type and behavior |
|---|---|
isOpen | boolean · requiredWhether the drawer is open. Fully controlled; pair with onOpenChange. |
onOpenChange | (isOpen: boolean) => void · requiredCalled when the drawer requests an open-state change. Escape, scrim click, and the built-in close button call it with false. The caller owns the open state. With sibling drawers open, Escape only closes the last-opened one. |
label | string · requiredAccessible label for the drawer. Required; the drawer has no built-in heading to derive a name from. |
children | ReactNode · requiredDrawer content, rendered inside a full-height scrollable area. Compose your own header/body/footer; an element with data-autofocus is focused on open. Children stay mounted during the exit animation; keep the last-selected item rendered instead of nulling content on close. |
side | 'start' | 'end' · default 'end'Edge the drawer slides from: 'end' is right in LTR (the inspector convention), 'start' is left. Inline axis only; for a bottom sheet use BottomSheet. |
width | number | string · default 400Desktop width budget. A number is pixels; a string is any CSS length ('50%', '32rem'). Below the 640px mobile breakpoint this remains the maximum while the drawer preserves a 56px reveal of the page behind. |
isFullWidthOnMobile | boolean · default falseCover the full viewport width below the 640px mobile breakpoint instead of preserving the default 56px reveal of the page behind. The reveal makes the drawer read as an overlay, not a navigation. |
hasScrim | boolean · default trueModal scrim behind the drawer. true uses showModal() (top layer, focus trap, scroll lock; clicking the scrim closes; modal only); false uses the manual Popover API for a non-modal top-layer overlay that does NOT trap focus and keeps the page behind interactive. |
hasCloseButton | boolean · default trueBuilt-in close button in the top-trailing corner. Enabled by default for both modal and non-modal drawers so every overlay has an obvious dismissal affordance. |
Anatomy
The root <dialog> element: a full-height surface anchored to the inline start or end edge, flush with three viewport edges (square corners).
Full-bleed scrollable region that receives children; compose your own header, body, and footer inside it.
Built-in dismissal affordance floating in the top-trailing corner; pass hasCloseButton={false} when the content provides its own.
Backdrop that dims and blocks the page behind a modal drawer (hasScrim, the default); a non-modal drawer renders none.
Theming
Targets
astryx-drawerVisual props: side