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.
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:
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.
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).
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.
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).
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.
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
Prop
Type
Default
Description
side
"top" | "right" | "bottom" | "left"
"bottom"
Edge the drawer slides out from.
open
boolean
—
Controlled open state. Pair with onOpenChange.
onOpenChange
(open: boolean) => void
—
Fires when the drawer opens or closes.
defaultOpen
boolean
false
Uncontrolled initial open state.
Drawer.Trigger
Prop
Type
Default
Description
asChild
boolean
false
Render trigger props onto the child element via vaul's Slot.
className
string
—
Merged onto the rendered element.
Drawer.Content
Prop
Type
Default
Description
withPortal
boolean
true
Auto-wrap in Portal and Overlay. Set false to compose them yourself.
overlayClassName
string
—
Merged onto the auto-rendered overlay (only when withPortal is true).
className
string
—
Merged onto the rendered panel.
Drawer.Header / Drawer.Footer
Prop
Type
Default
Description
className
string
—
Merged onto the rendered <div>.
Drawer.Title / Drawer.Description
Prop
Type
Default
Description
className
string
—
Merged onto the rendered element.
Drawer.Close
Prop
Type
Default
Description
asChild
boolean
false
Render close props onto the child element via vaul's Slot.
className
string
—
Merged 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.