Drawer
A swipeable panel anchored to a viewport edge — four directions, snap points, a swipe handle, nesting, and a non-modal mode.
- Status
- Since
0.10.0- Accessibility pattern
- APG dialog (modal by default)
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Drawer before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify drawerInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/drawerRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/use-modal-inert.
Usage
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>Open</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
<DrawerDescription>This action cannot be undone.</DrawerDescription>
</DrawerHeader>
<div className="p-4">{/* Content here */}</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose render={<Button variant="secondary" />}>Cancel</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>;On iOS Safari the overlay is absolutely positioned and needs a positioned body to cover the
viewport once the page has scrolled, so add body { position: relative } to your global styles.
Anatomy
Examples
Composition
DrawerContent composes the portal, the overlay, the viewport and the popup, so a drawer is a
trigger and a content region at the call site. DrawerPortal, DrawerOverlay and
DrawerSwipeHandle are exported too, for the cases that need lower-level control.
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooter
└── DrawerCloseCustom Sizes
A vertical drawer sizes itself to its content and caps at calc(100dvh - 6rem). A side drawer spans
75% of the viewport width, or 24rem above the sm breakpoint. Override the height with h-* and
max-h-* on DrawerContent, the width with w-* and max-w-*, and scope an override to one axis
with the data-[swipe-axis=*] variants when the same component renders in both. To make a region
scrollable, make it a flex item (flex-1 overflow-y-auto) — h-full does not resolve inside a
content-sized drawer.
Styling
The drawer exposes CSS variables for style-level customisation. Set the sizing variables on
DrawerContent; set the overlay variable on [data-slot=drawer-overlay] in your own CSS.
| Variable | Default | Description |
|---|---|---|
--drawer-inset | --spacing(2) | Floats the drawer off the viewport edges. 0px with flush. |
--drawer-bleed-background | transparent | Fills the gap behind the drawer on swipe overshoot. var(--color-popover) with flush. |
--drawer-overlay-min-opacity | 0 | Minimum overlay opacity. Defaults to 0.5 when snap points are active. |
The popup also carries data attributes to target with variants such as data-[swipe-direction=down]:
on DrawerContent, or group-data-[swipe-axis=y]/drawer-popup: on its descendants.
| Attribute | Values | Set when |
|---|---|---|
data-swipe-direction | up, right, down, left | Always. |
data-swipe-axis | x, y | Always. |
data-snap-points | Present | The drawer has snap points. |
data-expanded | Present | The drawer is at the full snap point. |
data-swiping | Present | A swipe is in progress. |
data-nested-drawer-open | Present | A nested drawer is open on top. |
Inset and flush
A drawer floats inset by default: --spacing(2) off every viewport edge, every corner rounded and a
full border, in all four directions. Pass flush on Drawer for the edge-to-edge sheet — rounded and
bordered on its inner edge only. Width and height overrides work the same either way: w-* and
max-w-* on DrawerContent (or a data-[swipe-axis=x]: scoped --drawer-content-width), and a
different inset with [--drawer-inset:--spacing(4)].
Position
swipeDirection on Drawer picks the edge the panel rests against, and the gesture that dismisses
it: up, right, down (the default) and left. It also decides the axis — up/down are the
y axis, left/right the x axis — which is what the data-swipe-axis variants read.
Swipe Handle
showSwipeHandle on Drawer renders the grab handle above the content. It is aria-hidden
decoration for the pointer gesture, so the drawer always keeps a real close control beside it.
Nested
A drawer opened from inside another drawer stacks: the parent stays mounted, takes
data-nested-drawer-open, and scales and dims behind the frontmost panel. Nest in the same
direction — the stack maths assumes one axis.
Non Modal
modal={false} leaves the rest of the page scrollable, clickable and reachable by keyboard, and
renders no overlay. Pair it with disablePointerDismissal so an outside press does not close the
panel. modal="trap-focus" is the middle setting: focus stays inside the drawer, but scroll and
pointer interaction outside it are untouched.
Snap Points
snapPoints on Drawer is the list of heights the panel settles at. A number between 0 and 1 is
a fraction of the viewport, a number above 1 is a pixel value, and a string carries px or rem
units. Snap points apply to vertical drawers.
Read and write the active one with the controlled pair snapPoint and onSnapPointChange — that is
how a value moves without a gesture, which is what a keyboard or a button needs. Both the popup and
the overlay carry data-snap-points while the list is in effect, and the overlay's minimum opacity
rises to 0.5 so the page behind never fully returns at the smallest snap point. At the full snap
point (1) the popup gains data-expanded, which the data-expanded: variant styles.
Responsive
Pair Drawer with Dialog at a breakpoint to get a sheet on a phone and a centred dialog on a
desktop. useMediaQuery is the system's one matchMedia subscription; give it
serverFallback: true for a max-width query so a phone never renders the desktop branch for a
frame before hydration corrects it.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
flush | boolean | — | |
showSwipeHandle | boolean | — |
DrawerContent adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
DrawerTrigger, DrawerClose, DrawerPortal, DrawerOverlay, DrawerSwipeHandle,
DrawerHeader, DrawerFooter, DrawerTitle and DrawerDescription add no props of their own —
each accepts everything the Base UI part, or the underlying div, accepts.
Accessibility
- Renders Base UI's Drawer: the popup is a modal
role="dialog"labelled byDrawerTitleand described byDrawerDescription, focus moves into it on open and returns to the trigger on close, and Esc dismisses it. - While a modal drawer is open the background is mirrored to the native
inertproperty (A11Y-9), so sequential keyboard focus cannot escape into it even during the frame a focus guard hands focus to<body>.modal={false}andmodal="trap-focus"deliberately do not, because both preserve outside pointer interaction. - The swipe handle is
aria-hiddenand pointer-only. Every drawer therefore keeps a real close control — aDrawerClose, Esc, or an outside press — and the snap point is reachable from the keyboard through the controlledsnapPointprop. - The popup portals out of the trigger's subtree, so
DrawerPortalre-applies the theme scope on adisplay: contentshost inside the portal (OVL-13) — a dark or scoped surface survives the portal. - Focus is the global 2px
:focus-visibleoutline frombase.css; there is no ring glow anywhere in the panel.
| Contract | States tested |
|---|---|
| Behaviour | default, open, closed, snapped, swiping, nested, modal, non-modal |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation |
| Visual | default, open, closed, swiping, nested |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/drawer.patch. Every hunk:
- OVL-13 —
DrawerPortalreadsuseInternalThemeScope()and wraps its children in adisplay: contentselement carrying that class. - A11Y-9 —
DrawerContentmirrors Base UI'sdata-base-ui-inertmarkers onto nativeinertthroughuseModalInert. No new context: upstream's ownDrawerContextalready carriesmodalintoDrawerContent, so the hook is gated on the value that is already there — which is whymodal={false}keeps its outside pointer interaction. - OVL-21 — the popup floats inset by default:
[--drawer-inset:--spacing(2)],rounded-xl, a fullborderand a transparent bleed.Drawertakesflush(carried on upstream's ownDrawerContext, markeddata-flushon the popup), which restores upstream's inner-edge rounding and border with the inset at0px. - LAY-17 — the popup sets
wrap-break-word, so a long unbroken word breaks inside the drawer rather than running past its edge. MK, 2026-10-13. - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header; no decision of its own.