shadcn ui : Drawer Syntax
Drawer is shadcn's mobile-first sliding surface. Unlike Dialog and Sheet (both Radix Dialog under the hood), Drawer wraps the Vaul library by Emil Kowalski (emilkowalski/vaul). Vaul re-implements the WAI-ARIA Dialog pattern with native-feeling drag gestures, snap points, and background scaling, then exposes a Radix-Dialog-compatible composition surface. That means the controlled-state contract and a11y rules from shadcn-syntax-dialog carry over verbatim, while the gesture / snap-point / scale-background features are Vaul-specific.
Quick Reference
The 10 primitives
| Primitive | Purpose |
|---|---|
Drawer |
Root state container ; owns open / onOpenChange / defaultOpen / modal / direction / snapPoints / shouldScaleBackground / dismissible / nested |
DrawerTrigger |
Element that opens the drawer ; asChild-aware |
DrawerPortal |
Renders Overlay + Content inside document.body (or container) |
DrawerOverlay |
Backdrop ; styled by shadcn with bg-black/50 + fade |
DrawerContent |
Main surface ; AUTO-WRAPS in DrawerPortal + DrawerOverlay ; renders a drag-handle only when direction="bottom" ; per-direction Tailwind variants driven by data-vaul-drawer-direction |
DrawerHeader |
Layout div ; auto-centers text when direction is top or bottom |
DrawerFooter |
Layout div with mt-auto so it pins to the bottom of the surface |
DrawerTitle |
Accessible name (REQUIRED, same as DialogTitle) |
DrawerDescription |
Accessible description (optional but recommended) |
DrawerClose |
Closes the drawer ; asChild-aware |
Minimal Drawer (uncontrolled, default direction bottom)
"use client"
import {
Drawer, DrawerTrigger, DrawerContent,
DrawerHeader, DrawerFooter,
DrawerTitle, DrawerDescription, DrawerClose,
} from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
<Drawer>
<DrawerTrigger asChild>
<Button>Open</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Move file</DrawerTitle>
<DrawerDescription>Pick a destination folder.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button>Move</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
Notice : no direction prop. Vaul defaults to "bottom". The drag-handle (a small bg-muted pill) renders automatically because direction is bottom.
Five invariants
- ALWAYS provide a
DrawerTitle. NEVER omit it ; Vaul forwards the Radix Dialog a11y contract, so missing title triggers the same dev-mode warning and axe-core critical flag as Dialog. - ALWAYS either control BOTH
openandonOpenChange, or control NEITHER. NEVER passopenalone (the drawer becomes read-only ; identical failure mode to Dialog because Vaul mirrors the contract). - ALWAYS keep the
"use client"directive at the top ofcomponents/ui/drawer.tsx. NEVER strip it ; Vaul uses pointer-events, refs, and Portal mounting. - ALWAYS render
<DrawerContent>directly (it wraps itself inDrawerPortal+DrawerOverlay). NEVER hand-wrap unless you are deliberately retargetingcontainer; in that case render the explicit<DrawerPortal container={el}>form. - ALWAYS use Drawer for mobile-first bottom-sheets. NEVER reach for Drawer to build a desktop side panel ; that is
shadcn-syntax-sheetterritory.
Decision Tree 1 : Drawer or Sheet or Dialog?
Q1. Is the surface a CENTERED modal (e.g., confirm dialog, profile edit
modal) on every viewport?
yes -> Dialog. Stop. Read shadcn-syntax-dialog.
no -> Q2
Q2. Is the surface a DESKTOP side panel that slides in from an edge
(left nav, right filters, top notification tray) and the target
audience is mostly mouse / pointer users ?
yes -> Sheet. Stop. Read shadcn-syntax-sheet.
no -> Q3
Q3. Is the surface MOBILE-FIRST, draggable, possibly with snap points,
and should the page behind scale and round on open (iOS sheet
aesthetic) ?
yes -> Drawer. Continue here.
no -> Q4
Q4. Does the UI need to ADAPT across viewports (centered dialog on
desktop, bottom-sheet on mobile) ?
yes -> Read shadcn-impl-responsive-dialog-drawer (B10) for the
useMediaQuery + shared content recipe.
no -> Re-read Q1 ; the surface probably is a Dialog or Sheet.
NEVER use Drawer with direction="left" or direction="right" to replace Sheet on desktop. Both libraries can render left/right surfaces, but Sheet is wired to Radix Dialog's focus model and animations and is the documented desktop primitive. Drawer's left/right modes exist mainly for the responsive-mobile-pattern.
Decision Tree 2 : Snap points : how many and what values?
Q1. Does the drawer have a SINGLE resting state (fully open, swipe to
close) ?
yes -> Omit `snapPoints` entirely. Vaul uses content height.
no -> Q2
Q2. Does the drawer have 2-3 stops (e.g., peek -> half -> full) ?
yes -> Pass `snapPoints={[0.4, 0.75, 1]}` (fractions of viewport)
or `snapPoints={["148px", "355px", 1]}` (mixed px + fraction).
The LAST value is the fully-open state.
no -> Q3
Q3. Does the drawer have more than 3 stops?
yes -> Reconsider UX. More than 3 snap-stops confuses users.
Vaul supports any count, but UX cost grows non-linearly.
no -> Drop snapPoints.
ALWAYS sort snapPoints ASCENDING. NEVER pass [0.75, 0.4, 1] ; Vaul snaps to whichever value is nearest by drag offset and an unordered array produces unpredictable jumps.
ALWAYS make the FIRST snap point at least as tall as your DrawerHeader. NEVER pass snapPoints={[0.1, 1]} when the header is p-4 + title + description : the drawer pops over its own header and the drag-handle disappears.
For controlled snap-point selection, pair activeSnapPoint with setActiveSnapPoint. Half-controlling here freezes the snap state the same way half-controlled open freezes visibility.
Decision Tree 3 : direction = top / bottom / left / right?
Q1. Mobile-first bottom-sheet (iOS / Android style)?
yes -> direction omitted (default "bottom"). Drag-handle renders.
no -> Q2
Q2. Notification tray or filter chips entering from the top edge?
yes -> direction="top". DrawerHeader auto-centers.
no -> Q3
Q3. Off-canvas menu sliding from left/right edge AND the UI is
mobile-first AND the responsive-dialog-drawer recipe does not apply?
yes -> direction="left" or "right". Width is 3/4 viewport by
default ; max-w-sm at sm: breakpoint.
no -> Reconsider Sheet.
The shadcn DrawerContent uses data-[vaul-drawer-direction=<dir>]:* Tailwind variants to swap fixed-position + border-radius classes per direction. This is verbatim from apps/v4/registry/new-york-v4/ui/drawer.tsx ; you can extend it the same way for custom directions.
The 10 primitives in detail
Drawer (Root)
Owns the state. Vaul Root accepts EVERY prop below ; the shadcn wrapper forwards them all via ...props.
| Prop | Type | Default | Notes |
|---|---|---|---|
open |
boolean |
- | Controlled visibility. PAIR WITH onOpenChange. |
defaultOpen |
boolean |
false |
Uncontrolled initial state. EXCLUDES open. |
onOpenChange |
(open: boolean) => void |
- | Fires on Trigger, Close, Esc, drag-dismiss. |
modal |
boolean |
true |
When false, page stays interactive. |
direction |
"top" | "bottom" | "left" | "right" |
"bottom" |
Edge the drawer enters from. |
snapPoints |
(number | string)[] |
- | Multi-stop drawer fractions / px values. |
activeSnapPoint |
number | string | null |
- | Controlled current snap. Pair with setActiveSnapPoint. |
setActiveSnapPoint |
(snap) => void |
- | Setter for activeSnapPoint. |
fadeFromIndex |
number |
last | Snap index at which overlay starts fading in. |
shouldScaleBackground |
boolean |
false |
Scale + round the [data-vaul-drawer-wrapper] element on open (iOS aesthetic). REQUIRES a wrapper. |
setBackgroundColorOnScale |
boolean |
true |
Tints body background black when scaling. |
dismissible |
boolean |
true |
When false, drawer ignores drag-dismiss + Esc. |
nested |
boolean |
false |
Set on inner Drawer when nesting drawers (avoids double-scale). |
handleOnly |
boolean |
false |
Drag only fires from the handle, not the whole content. |
closeThreshold |
number |
0.25 |
Fraction of height past which release closes the drawer. |
scrollLockTimeout |
number |
100 |
ms before re-enabling body scroll on close. |
onDrag |
(event, percentage) => void |
- | Fires on every pointer-move during drag. |
onRelease |
(event, willOpen) => void |
- | Fires on pointer-up. |
onAnimationEnd |
(open) => void |
- | Fires when open/close animation completes. |
repositionInputs |
boolean |
true |
Scroll focused input into view on mobile keyboard. |
disablePreventScroll |
boolean |
false |
Lets the page behind scroll while open. |
preventScrollRestoration |
boolean |
true |
Don't restore body scroll position on close. |
noBodyStyles |
boolean |
false |
Skip body-style manipulation entirely. |
autoFocus |
boolean |
false |
Auto-focus first focusable on open (matches Radix). |
container |
HTMLElement | null |
document.body |
Portal target. |
ALWAYS choose either defaultOpen or open+onOpenChange. NEVER both. Vaul prefers the controlled prop and ignores the default, identical to Radix.
DrawerTrigger
Element that toggles open to true. Default render is a <button type="button">. asChild to wrap any focusable element. Receives data-state="open|closed" for styling.
<DrawerTrigger asChild>
<Button variant="outline">Pick destination</Button>
</DrawerTrigger>
NEVER wrap two siblings in <DrawerTrigger asChild> ; Slot rejects multi-child.
DrawerPortal
Renders Overlay + Content inside document.body by default. Props :
container?: HTMLElement: alternate render target.forceMount?: boolean: keep mounted when closed (manual exit animation).
DrawerContent calls DrawerPortal internally with no props. To customise, use the explicit form.
DrawerOverlay
Backdrop. Styled by shadcn with fixed inset-0 z-50 bg-black/50 + fade animation via data-[state=open]:animate-in / data-[state=closed]:animate-out. Same z-index as DialogOverlay and SheetOverlay.
NEVER set pointer-events-none on the overlay : Vaul needs pointer events to fire drag-dismiss.
DrawerContent
The main surface. Auto-portals (wraps in DrawerPortal + DrawerOverlay). Key behaviours :
- Renders a
<div className="mx-auto mt-4 hidden h-2 w-[100px] ... bg-muted ..." />drag-handle that is hidden by default but unveiled by thegroup-data-[vaul-drawer-direction=bottom]/drawer-content:blockselector. So the handle only appears whendirection="bottom". - Per-direction Tailwind variants live in the className via
data-[vaul-drawer-direction=top|bottom|left|right]:*. Top/bottom span full width withmax-h-[80vh]. Left/right span 3/4 viewport width (w-3/4) and cap atmax-w-smfrom thesm:breakpoint up. - Same
onOpenAutoFocus/onCloseAutoFocus/onEscapeKeyDown/onPointerDownOutside/onInteractOutsidecallbacks as Radix Dialog.
ALWAYS render exactly ONE <DrawerContent> per <Drawer>. NEVER conditionally render two and toggle which is shown.
DrawerHeader
Layout div with flex flex-col gap-0.5 p-4. Includes group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center and the top-direction equivalent, so the header auto-centers on top/bottom drawers. On md: the layout switches to text-left regardless of direction.
DrawerFooter
Layout div with mt-auto flex flex-col gap-2 p-4. The mt-auto is what pins the footer to the bottom of the drawer surface. There is NO showCloseButton prop here (unlike DialogFooter in v4) ; if you need an explicit close button, render <DrawerClose asChild> yourself.
DrawerTitle
REQUIRED. Renders the accessible name. Styled with font-semibold text-foreground. Vaul forwards Radix Dialog's a11y wiring : without DrawerTitle, the same console warning and axe-core critical flag fires as for Dialog.
To hide visually : <DrawerTitle className="sr-only">…</DrawerTitle> or wrap with <VisuallyHidden asChild>.
DrawerDescription
Optional supplemental text styled with text-sm text-muted-foreground. If you intentionally omit it, ALSO pass aria-describedby={undefined} to <DrawerContent> to suppress the Radix-style warning forwarded by Vaul.
DrawerClose
Closes the drawer. Default render is <button type="button">. asChild to wrap any focusable element. Useful inside DrawerFooter for an explicit Cancel button.
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
a11y requirement : DrawerTitle is non-optional
Vaul forwards the WAI-ARIA Dialog pattern. Same rule, same failure surface as shadcn-syntax-dialog :
- Missing DrawerTitle logs a development warning to the console.
- axe-core / axe DevTools / Lighthouse a11y audit flag the drawer as critical (no accessible name).
- Screen-reader users hear "dialog" with no further context.
If your drawer has no visible heading (e.g., a media-controls drawer), ALWAYS add <DrawerTitle className="sr-only">…</DrawerTitle>. NEVER drop the title primitive.
Vaul-specific behaviours
shouldScaleBackground
When true, Vaul scales and rounds the element with data-vaul-drawer-wrapper while the drawer is open, producing the iOS-style stacked sheet look. The wrapper element MUST exist :
<div data-vaul-drawer-wrapper className="bg-background min-h-screen">
{children /* the whole app */}
</div>
Without the wrapper, shouldScaleBackground is a no-op. Pair with setBackgroundColorOnScale={false} if your app already has a dark background so Vaul does not over-tint.
Snap points
snapPoints={[0.4, 0.75, 1]} produces three resting positions at 40 %, 75 %, and 100 % of viewport height (for direction="bottom" and "top") or width (for "left" and "right"). Mixed units are allowed : snapPoints={["148px", "355px", 1]}. Vaul interpolates the overlay fade from the index at fadeFromIndex (defaults to the last index).
For controlled snap selection :
const [snap, setSnap] = useState<number | string | null>("148px")
<Drawer
snapPoints={["148px", "355px", 1]}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
>…</Drawer>
dismissible / handleOnly / closeThreshold
dismissible={false}disables drag-to-close AND Esc-to-close. Use for mandatory flows (paywall, terms-acceptance).handleOnly={true}confines drag-initiation to the drag-handle. Useful when the content has scrollable areas you do NOT want the user to accidentally drag.closeThreshold={0.5}raises the drag-distance required to close. Default0.25.
Nested drawers
Set nested on the INNER drawer. Vaul then avoids re-scaling the wrapper and instead stacks the inner drawer over the outer with separate overlays.
<Drawer>
<DrawerContent>
…
<Drawer nested>
<DrawerContent>…</DrawerContent>
</Drawer>
</DrawerContent>
</Drawer>
Portal rendering and z-index
<DrawerPortal> mounts inside document.body by default. Same stacking behaviour as <DialogPortal> :
- Drawer escapes any
overflow: hidden,transform,filter, orwill-changeancestor. - DrawerOverlay sits at
z-50. Any Popover / DropdownMenu / Tooltip / HoverCard mounted INSIDE the drawer will need a higher z-index (default Radix popper isz-50too) or it stacks under the drawer's content surface unpredictably. The shadcn evergreen-2026 Tooltip defaults toz-50and SHOULD work, but custom popovers may need az-[60]override. - Nested drawers each use their own portal scope. Set
nestedon the inner Drawer.
To retarget (rare) :
<DrawerPortal container={hostRef.current}>
<DrawerOverlay />
<DrawerContent>…</DrawerContent>
</DrawerPortal>
RSC and the "use client" directive
The shadcn drawer.tsx ships with "use client" at the top. Same rationale as Dialog :
- Vaul uses pointer events, refs, and
useEffectfor body-style mutations. - Snap-point math runs in
useState+useLayoutEffect. - The Portal mounts client-side.
ALWAYS keep "use client" in components/ui/drawer.tsx. NEVER strip it.
Components that consume <Drawer> may live in Server Components, but the drawer file itself draws the client boundary. See shadcn-impl-rsc-vs-client-boundaries for the broader RSC contract.
Companion Skills
- shadcn-syntax-dialog : the centered modal pattern ; shares the controlled-state contract and a11y rules.
- shadcn-syntax-sheet : Dialog-derivative for desktop side panels ; read when the surface is NOT mobile-first.
- shadcn-impl-responsive-dialog-drawer (Batch B10) : Dialog-on-desktop / Drawer-on-mobile recipe using
useMediaQuery; the canonical responsive pattern that combines this skill withshadcn-syntax-dialog. - shadcn-errors-radix-controlled : the controlled-state failure catalogue, applies to Drawer through Vaul's mirrored contract.
- shadcn-syntax-button : the
asChildpattern used by DrawerTrigger and DrawerClose.
Reference Links
- references/methods.md : verbatim prop signatures for all ten primitives and the full Vaul Root prop surface.
- references/examples.md : six canonical recipes (minimal bottom drawer, multi-snapPoint drawer, direction left drawer, controlled drawer + form, shouldScaleBackground, mobile-only via useMediaQuery).
- references/anti-patterns.md : five anti-patterns with WHY and FIX.
Sources
All claims trace to URLs in SOURCES.md :
- https://ui.shadcn.com/docs/components/radix/drawer (canonical Drawer docs, recipe with snap points)
- https://github.com/emilkowalski/vaul (Vaul library : props, snap-point semantics, shouldScaleBackground behaviour)
- https://github.com/shadcn-ui/ui (apps/v4/registry/new-york-v4/ui/drawer.tsx, verbatim v4 source ; per-direction variants ; drag-handle rendering)
- https://www.radix-ui.com/primitives/docs/components/dialog (a11y contract that Vaul mirrors)
- https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ (WAI-ARIA Dialog pattern)
Verified 2026-05-19.