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
pnpm dlx @hex-core/cli add drawer

Controlled drawer

Open from a non-button surface (e.g. a row click on a data table) by lifting open state to the parent

tsx
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

tsx
<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

PropTypeDefaultDescription
open
booleanControlled open state
defaultOpen
booleanfalseDefault open state
onOpenChange
functionCallback when open state changes: (open: boolean) => void
shouldScaleBackground
booleantrueScale the <body> element when the drawer opens (creates depth)
snapPoints
objectArray of snap positions ('40%', 400, '100%') — defines resting heights the user can snap to
activeSnapPoint
objectControlled active snap point value (matches one entry in snapPoints)
closeThreshold
number0.25Fraction of height the user must drag down to close (0..1)
dismissible
booleantrueAllow 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).

Related components

Token budget: 700