Drawer
Bottom-sheet drawer built on vaul. Mobile-native feel: drag-to-dismiss, snap points, body-scale-on-open. Use for quick mobile actions, filters, pickers.
Installation
pnpm dlx @hex-core/cli add drawerControlled drawer
Open from a non-button surface (e.g. a row click on a data table) by lifting open state to the parent
const [openInvoice, setOpenInvoice] = useState<Invoice | null>(null);
return (
<>
<table>
<tbody>
{invoices.map((invoice) => (
<tr
key={invoice.id}
onClick={() => setOpenInvoice(invoice)}
className="cursor-pointer hover:bg-muted/40"
>
<td className="px-3 py-2">{invoice.id}</td>
<td className="px-3 py-2">{invoice.customer}</td>
</tr>
))}
</tbody>
</table>
<Drawer open={openInvoice !== null} onOpenChange={(o) => !o && setOpenInvoice(null)}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{openInvoice?.id}</DrawerTitle>
<DrawerDescription>{openInvoice?.customer}</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-4">
<p className="text-sm text-muted-foreground">
Amount due: {openInvoice?.amount}
</p>
</div>
</DrawerContent>
</Drawer>
</>
);With nested form sections
Stack labelled inputs inside the drawer body and pin Save/Cancel to DrawerFooter — useful for filter or quick-edit panels
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Filters</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Filter invoices</DrawerTitle>
<DrawerDescription>Narrow the list to a specific window.</DrawerDescription>
</DrawerHeader>
<Stack gap="md" className="px-4 pb-4">
<div className="grid gap-1.5">
<Label htmlFor="customer">Customer</Label>
<Input id="customer" placeholder="Acme, Inc." />
</div>
<div className="grid gap-1.5">
<Label htmlFor="status">Status</Label>
<Input id="status" placeholder="Paid, Pending…" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="min">Minimum amount</Label>
<Input id="min" type="number" placeholder="0" />
</div>
</Stack>
<DrawerFooter>
<Button>Apply filters</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Default open state |
onOpenChange | function | — | Callback when open state changes: (open: boolean) => void |
shouldScaleBackground | boolean | true | Scale the <body> element when the drawer opens (creates depth) |
snapPoints | object | — | Array of snap positions ('40%', 400, '100%') — defines resting heights the user can snap to |
activeSnapPoint | object | — | Controlled active snap point value (matches one entry in snapPoints) |
closeThreshold | number | 0.25 | Fraction of height the user must drag down to close (0..1) |
dismissible | boolean | true | Allow drag-to-dismiss and outside-click-to-dismiss |
AI Guidance
When to use
Use on mobile-first or mobile-primary UX when a native app-like bottom sheet matters. Good for filters, quick pickers, confirm-then-do flows, or anywhere a user expects drag-to-dismiss.
When not to use
Don't use on desktop-primary UIs (use Dialog or Sheet). Don't use for side navigation (use Sheet). Don't use for transient info (use Popover or Tooltip). Don't use when you must prevent dismissal — drawers invite drag-down.
Common mistakes
- Forgetting DrawerTitle — vaul/Radix warn and screen readers announce an unnamed dialog
- Placing long forms inside a drawer without snap points — content gets cramped
- Disabling shouldScaleBackground when the background context-cue matters for UX
- Wrapping DrawerContent in Portal yourself — DrawerContent already portals via DrawerPortal
Accessibility
vaul delegates to Radix Dialog: focus trap, Escape to close, aria-labelledby/describedby wired to DrawerTitle/Description. The top handle is decorative (aria-hidden).
Token budget: 700
Verified against @hex-core/components@1.12.0