Drawer

A side panel that slides in from the top, right, bottom, or left edge of the viewport. Built on vaul so it ships with native swipe-to-dismiss, snap points, and gesture-aware physics. Use this for mobile sheets, settings panels, and navigation drawers — anywhere the content lives at a viewport edge instead of centered like a Dialog.

Installation

npx shadcn@latest add https://craftbits.dev/r/drawer.json

Usage

Drawer is a compound — Root owns open state and the active side, Trigger opens it, Content is the panel (auto-portaled with its own overlay), Header stacks the Title and Description, Footer pins actions to the bottom, and Close dismisses it.

import { Drawer } from "@craft-bits/core";
 
<Drawer.Root side="right">
  <Drawer.Trigger asChild>
    <button>Open settings</button>
  </Drawer.Trigger>
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>Settings</Drawer.Title>
      <Drawer.Description>
        Configure how the app behaves on this device.
      </Drawer.Description>
    </Drawer.Header>
    <Drawer.Footer>
      <Drawer.Close>Done</Drawer.Close>
    </Drawer.Footer>
  </Drawer.Content>
</Drawer.Root>

Drive the open state from your own store by passing open plus onOpenChange:

const [open, setOpen] = useState(false);
 
<Drawer.Root open={open} onOpenChange={setOpen} side="bottom">
  {/* ... */}
</Drawer.Root>

Understanding the component

  1. Drawer vs. Dialog. A Dialog is centered and modal; a Drawer is edge-anchored and gesture-aware. Reach for Drawer when the content has a natural "out of the way" position — settings off the side, a sheet from the bottom, a nav rail from the left.
  2. Compound parts. Root (state owner, accepts side), Trigger (opens the drawer, accepts asChild), Overlay (backdrop scrim), Content (the panel; auto-wraps in Portal and Overlay unless you pass withPortal={false}), Header and Footer (layout helpers), Title and Description (vaul-wired aria-labelledby and aria-describedby), Close (the dismiss trigger).
  3. The side prop drives everything. side="bottom" slides up with a grab pill at the top; side="top" mirrors that from the top edge; side="left" and side="right" slide in horizontally and cap their width at sm:max-w-sm so they do not dominate wider screens.
  4. Controlled or uncontrolled. Omit open and onOpenChange to let vaul manage state. Pass both to drive open/close from your own state — useful for triggering the drawer from a remote action (keyboard shortcut, async callback, deep link).
  5. Gestures come for free. vaul handles touch-drag-to-dismiss and the inertia physics automatically. The grab pill on bottom drawers is the visual affordance — it appears via group-data-[vaul-drawer-direction=bottom] so it never shows on side drawers.
  6. asChild on Trigger and Close. Both accept asChild — drop in your own Button primitive and the drawer's event handlers merge onto it via the underlying Slot.

Props

Drawer.Root

PropTypeDefaultDescription
side"top" | "right" | "bottom" | "left""bottom"Edge the drawer slides out from.
openbooleanControlled open state. Pair with onOpenChange.
onOpenChange(open: boolean) => voidFires when the drawer opens or closes.
defaultOpenbooleanfalseUncontrolled initial open state.

Drawer.Trigger

PropTypeDefaultDescription
asChildbooleanfalseRender trigger props onto the child element via vaul's Slot.
classNamestringMerged onto the rendered element.

Drawer.Content

PropTypeDefaultDescription
withPortalbooleantrueAuto-wrap in Portal and Overlay. Set false to compose them yourself.
overlayClassNamestringMerged onto the auto-rendered overlay (only when withPortal is true).
classNamestringMerged onto the rendered panel.

Drawer.Header / Drawer.Footer

PropTypeDefaultDescription
classNamestringMerged onto the rendered <div>.

Drawer.Title / Drawer.Description

PropTypeDefaultDescription
classNamestringMerged onto the rendered element.

Drawer.Close

PropTypeDefaultDescription
asChildbooleanfalseRender close props onto the child element via vaul's Slot.
classNamestringMerged onto the rendered button.

Accessibility

  • Vaul renders the panel as role="dialog" and traps focus inside until the drawer closes.
  • Title is wired as the panel's accessible name (aria-labelledby); Description is wired as its description (aria-describedby). Both are required — wrap them in sr-only if your visual design has none.
  • Escape and backdrop clicks dismiss the drawer (override via vaul's dismissible={false}).
  • The trigger receives focus back when the drawer closes so keyboard users return to where they were.
  • Swipe-to-dismiss is pointer/touch only — keyboard users always have Escape.
  • Animations honour prefers-reduced-motion via the data-[state=*]:animate-in / animate-out Tailwind classes (pair with tw-animate-css, which respects the OS setting).

Credits

  • Extracted from: algoflashcards (src/platform/ui/drawer.tsx). The original used the project's font-heading token and a translucent before: pseudo-element panel; craft-bits drops the pseudo-element trick in favour of a flat cb-* themed surface and exposes a single side prop instead of relying on vaul's direction directly.
  • Built on: vaul by Emil Kowalski.