SuperAPI AI Directory

shadcn-syntax-drawer

General & other

View repository

Updated 10-15 22:18

About this skill

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

  1. 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.
  2. ALWAYS either control BOTH open and onOpenChange, or control NEITHER. NEVER pass open alone (the drawer becomes read-only ; identical failure mode to Dialog because Vaul mirrors the contract).
  3. ALWAYS keep the "use client" directive at the top of components/ui/drawer.tsx. NEVER strip it ; Vaul uses pointer-events, refs, and Portal mounting.
  4. ALWAYS render <DrawerContent> directly (it wraps itself in DrawerPortal + DrawerOverlay). NEVER hand-wrap unless you are deliberately retargeting container ; in that case render the explicit <DrawerPortal container={el}> form.
  5. ALWAYS use Drawer for mobile-first bottom-sheets. NEVER reach for Drawer to build a desktop side panel ; that is shadcn-syntax-sheet territory.

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 the group-data-[vaul-drawer-direction=bottom]/drawer-content:block selector. So the handle only appears when direction="bottom".
  • Per-direction Tailwind variants live in the className via data-[vaul-drawer-direction=top|bottom|left|right]:*. Top/bottom span full width with max-h-[80vh]. Left/right span 3/4 viewport width (w-3/4) and cap at max-w-sm from the sm: breakpoint up.
  • Same onOpenAutoFocus / onCloseAutoFocus / onEscapeKeyDown / onPointerDownOutside / onInteractOutside callbacks 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 :

  1. Missing DrawerTitle logs a development warning to the console.
  2. axe-core / axe DevTools / Lighthouse a11y audit flag the drawer as critical (no accessible name).
  3. 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. Default 0.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, or will-change ancestor.
  • DrawerOverlay sits at z-50. Any Popover / DropdownMenu / Tooltip / HoverCard mounted INSIDE the drawer will need a higher z-index (default Radix popper is z-50 too) or it stacks under the drawer's content surface unpredictably. The shadcn evergreen-2026 Tooltip defaults to z-50 and SHOULD work, but custom popovers may need a z-[60] override.
  • Nested drawers each use their own portal scope. Set nested on 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 useEffect for 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

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 :

Verified 2026-05-19.