Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Drawer

A swipeable panel anchored to a viewport edge — four directions, snap points, a swipe handle, nesting, and a non-modal mode.

Status
stable
Since
0.10.0
Accessibility pattern
APG dialog (modal by default)

Last updated

Install

Add Drawer from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/drawer

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="outline" />}>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

Drawer — data-slot="drawer"
DrawerPortal — data-slot="drawer-portal"
DrawerOverlay — data-slot="drawer-overlay"
DrawerSwipeHandle — data-slot="drawer-swipe-handle"
DrawerTrigger — data-slot="drawer-trigger"
DrawerClose — data-slot="drawer-close"
DrawerContent — data-slot="drawer-content" | "drawer-popup" | "drawer-portal" | "drawer-viewport"
DrawerHeader — data-slot="drawer-header"
DrawerFooter — data-slot="drawer-footer"
DrawerTitle — data-slot="drawer-title"
DrawerDescription — data-slot="drawer-description"

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
        └── DrawerClose

Custom 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.

VariableDefaultDescription
--drawer-inset0pxFloats the drawer off the viewport edges.
--drawer-bleed-backgroundvar(--color-popover)Fills the gap behind the drawer on swipe overshoot.
--drawer-overlay-min-opacity0Minimum 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.

AttributeValuesSet when
data-swipe-directionup, right, down, leftAlways.
data-swipe-axisx, yAlways.
data-snap-pointsPresentThe drawer has snap points.
data-expandedPresentThe drawer is at the full snap point.
data-swipingPresentA swipe is in progress.
data-nested-drawer-openPresentA nested drawer is open on top.

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

PropTypeDefaultDescription
showSwipeHandleboolean

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 by DrawerTitle and described by DrawerDescription, 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 inert property (A11Y-9), so sequential keyboard focus cannot escape into it even during the frame a focus guard hands focus to <body>. modal={false} and modal="trap-focus" deliberately do not, because both preserve outside pointer interaction.
  • The swipe handle is aria-hidden and pointer-only. Every drawer therefore keeps a real close control — a DrawerClose, Esc, or an outside press — and the snap point is reachable from the keyboard through the controlled snapPoint prop.
  • The popup portals out of the trigger's subtree, so DrawerPortal re-applies the theme scope on a display: contents host inside the portal (OVL-13) — a dark or scoped surface survives the portal.
  • Focus is the global 2px :focus-visible outline from base.css; there is no ring glow anywhere in the panel.
ContractStates tested
Behaviourdefault, open, closed, snapped, swiping, nested, modal, non-modal
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation
Visualdefault, open, closed, swiping, nested

Do / Don't

Do
Use a Drawer for a panel the user pulls in from an edge — a filter sheet, a detail pane, a mobile form — and give it a title and a close control.
Don't
Rely on the swipe handle as the only way out, or nest drawers in different directions; the stack maths assumes one axis.

Deviations

Upstream's file plus packages/ui/upstream/patches/drawer.patch. Every hunk:

  • OVL-13DrawerPortal reads useInternalThemeScope() and wraps its children in a display: contents element carrying that class.
  • A11Y-9DrawerContent mirrors Base UI's data-base-ui-inert markers onto native inert through useModalInert. No new context: upstream's own DrawerContext already carries modal into DrawerContent, so the hook is gated on the value that is already there — which is why modal={false} keeps its outside pointer interaction.
  • DOC-2import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header; no decision of its own.

On this page