Designer view

Drawer

An edge-anchored panel that slides in from a viewport edge to surface contextual content alongside (or temporarily replacing) the underlying view. Distinct from Modal in two ways: positioning is edge-anchored rather than centered, and the modal/non-modal behaviour is a property rather than the defining trait. Common uses: filter panels, detail-view side-sheets, mobile navigation, settings flyouts.

Also called Side panel Sheet

When to use

Use

When contextual content should surface alongside (or temporarily replacing) the underlying view from a viewport edge — filter panels, detail-view side-sheets, mobile navigation, mobile bottom-sheet pickers. Drawer is preferred over Modal when the relationship to the underlying content matters and the user benefits from continued visual context.

Avoid

For blocking decisions or destructive confirmations — that is `Modal[role=alertdialog]`. For contextual content tied to a specific trigger — that is `Popover`. For non-blocking inline notifications — that is `Toast` or `Banner`. For a permanent column that is part of the layout — that is a layout primitive, not a Drawer.

Versus related

  • modal

    `Modal` always centres and is always modal; `Drawer` is edge-anchored and can be modal or non-modal. Drawer preserves spatial relationship to the underlying content (the page is still partly visible); Modal severs that relationship.

  • popover

    `Popover` is anchored to a *trigger element* and floats near it; `Drawer` is anchored to a *viewport edge*. Popover is always non-modal and dismissable on outside click without ceremony; Drawer can be modal and may require explicit dismissal. Use Popover for content the user reads and dismisses; Drawer for content the user *acts in*.

  • alert

    `Alert` is a non-blocking inline message announced via `aria-live`. Drawer is a navigable surface; Alert is a static message. The `Drawer[variant=navigation]` is similar to a SidebarNav, not to Alert.

  • sidebar-nav

    `SidebarNav` is a persistent layout region that lives in the page chrome and hosts global navigation. `Drawer` is a transient overlay invoked on demand and dismissed. Mobile-collapsed SidebarNav often *renders* as a Drawer below `breakpoint.sm`, but the canonical reference treats them as separate components — the persistent vs. transient distinction is canonical even when the visual rendering converges on small viewports.

Drawer slides from a viewport edge to host secondary navigation, filters, contextual content, or task flows that accompany the main page rather than blocking it. It may be modal (blocks interaction with the page beneath) or non-modal (the page remains operable around it). Side, top, and bottom anchors are all canonical; mobile layouts often auto-flip to bottom for thumb-reach. The reference covers the dismiss-reason vocabulary, the modal-vs-non-modal switch, the responsive auto-flip event, and the focus-trap rules that diverge from Modal's hard contract.

Highlight
Fig 1.1 · Drawer · Designer view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

cdk MatDrawer / MatSidenav (from @angular/material/sidenav)
app.component.ts
import { Component, signal } from '@angular/core';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-shell',
standalone: true,
imports: [MatSidenavModule, MatButtonModule, MatIconModule],
template: `
<mat-drawer-container [hasBackdrop]="mode() !== 'side'">
<mat-drawer
#drawer
[mode]="mode()"
position="start"
[disableClose]="false"
autoFocus="first-tabbable"
(openedChange)="onOpenedChange($event)"
(closedStart)="onClosedStart()"
>
<button mat-icon-button (click)="drawer.close()" aria-label="Close filters">
<mat-icon>close</mat-icon>
</button>
<h2 id="drawer-title">Filters</h2>
<!-- body content projected directly into mat-drawer -->
<ng-content />
</mat-drawer>
<mat-drawer-content>
<button mat-button (click)="drawer.open()">Open Filters</button>
<!-- main page content -->
</mat-drawer-content>
</mat-drawer-container>
`,
})
export class AppShellComponent {
mode = signal<'over' | 'push' | 'side'>('over');
onOpenedChange(isOpen: boolean): void {
console.log('drawer', isOpen ? 'opened' : 'closed');
}
onClosedStart(): void {
// fires when close animation begins
}
}

Divergence

From Type → To Rationale
anatomy[container] reshaped Three-element compound: <mat-drawer-container> is the layout host that manages drawer + page-content side by side; <mat-drawer> is the panel itself (the drawable surface); <mat-drawer-content> is a sibling element that holds main-page content and shifts/compresses when mode="push" or mode="side". The canonical container is a single node bearing role="dialog" or role="region"; Material splits the role-bearing node (<mat-drawer>) from the layout host (<mat-drawer-container>) and the content-shift target (<mat-drawer-content>). Material Sidenav is designed as a full-page layout primitive, not just an overlay. The three-node split lets mat-drawer-container measure the drawer width and push mat-drawer-content aside in side/push modes without consumer-authored layout code. A single-node canonical container cannot express this because the shift target must be a sibling, not a child. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
anatomy[backdrop] reshaped Backdrop is rendered automatically by <mat-drawer-container> when mode="over" or mode="push" (default). Controlled by the container-level input `hasBackdrop: boolean | null`; null (default) follows per-mode defaults; true forces backdrop even in side mode; false suppresses it even in over/push modes. Backdrop click fires the container output `(backdropClick)`. There is no separate consumer-composed backdrop slot. Canonical backdrop is an optional anatomy slot the consumer composes. Material bakes backdrop into the container layer as a mode-driven automatic behaviour controlled by hasBackdrop, reducing consumer boilerplate. The trade-off is loss of the canonical slot's composability (consumers cannot swap in a custom scrim node). Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
anatomy[header] omitted No header primitive. <mat-drawer> accepts arbitrary content projection; the consumer renders whatever heading + close-affordance layout they choose. Angular Material does not ship a mat-drawer-header directive analogous to mat-dialog-title / mat-dialog-actions. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
anatomy[title] omitted No title primitive. The accessible name for the drawer must be provided by the consumer via aria-label on <mat-drawer> or by projecting a heading element and wiring aria-labelledby manually. MatDrawer does not auto-wire aria-labelledby from a projected heading. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
anatomy[close-button] omitted No close-button primitive. Consumers project a button and call `drawerRef.close()` (template reference) or toggle the `opened` input. Escape dismissal is controlled by the `disableClose: boolean` input (default false — Escape is enabled). There is no built-in visible close affordance; the consumer is responsible for providing one. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
anatomy[footer] omitted No footer primitive. Consumers project footer / action-row markup directly into <mat-drawer>. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
anatomy[handle] omitted No swipe-to-dismiss handle and no swipe gesture support. MatDrawer is designed for pointer and keyboard interaction on desktop; there is no drag-handle slot, no swipeable input, and no touch-velocity-based dismiss in the Material sidenav implementation. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
axes.variants reshaped `mode: 'over' | 'push' | 'side'` on <mat-drawer>. 'over' is the canonical modal-overlay variant (drawer floats above content with backdrop). 'side' is a persistent non-modal column (canonical navigation variant — drawer is always visible, no backdrop, content shifts). 'push' is a Material-specific intermediate: drawer slides in, main content is pushed aside (no overlay), backdrop shown by default. MatSidenav additionally adds fixedInViewport / fixedTopGap / fixedBottomGap for a pinned-navigation-rail sub-variant. Canonical variants are modal / non-modal / navigation. Material collapses all three into the `mode` input rather than a separate variant enum, and adds push as a fourth mode with no canonical equivalent (push displaces content without overlaying it, distinct from both overlay and side-column). Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
axes.properties[side] reshaped `position: 'start' | 'end'` on <mat-drawer> (default: 'start'). Two values only — logical inline-axis positions. block-start (top) and block-end (bottom) are not supported; MatDrawer is strictly a left/right-edge primitive. Canonical `side` has four values: inline-start, inline-end, block-start, block-end. Material restricts to the inline axis only (start / end), using logical property names. Top and bottom drawers are outside the Material Sidenav scope — bottom-sheet patterns use MatBottomSheet instead. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
axes.properties[dismissible] renamed `disableClose: boolean` on <mat-drawer> (default: false — dismiss enabled). Controls both Escape-key and backdrop-click dismissal together. Inverted polarity: dismissible=true ⇔ disableClose=false. Same inverted-polarity pattern as CDK Dialog (audited in modal.yaml). disableClose=false is the Angular convention for "allow dismiss"; canonical models this as dismissible=true. The two flags compose identically; only the name and polarity differ. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
axes.properties[swipeable] omitted No swipeable input and no swipe gesture support. MatDrawer targets desktop web; mobile swipe-to-dismiss is not part of the sidenav contract. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
events[openChange] reshaped Four outputs on MatDrawer: `(openedChange): EventEmitter<boolean>` fires after transition completes (true=opened, false=closed); `(openedStart)` and `(closedStart)` are Observables that fire when the respective animation begins; `(onPositionChanged)` fires when the position input changes. There is no single boolean openChange that maps exactly — openedChange matches the canonical payload but the name differs. Canonical openChange fires a boolean after settle. MatDrawer ships openedChange (same payload, after settle) plus animation-start observables for the opening and closing phases, giving consumers fine-grained lifecycle hooks the canonical does not model. onPositionChanged is an unrelated configuration event. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
events[dismiss] omitted No dismiss event and no reason payload. openedChange(false) signals the drawer closed but does not distinguish Escape, backdrop click, or programmatic close(). Consumers who need the dismissal reason must wire (backdropClick) on the container and (keydown.escape) on the drawer themselves. Source: github.com/angular/components blob src/material/sidenav/drawer.ts (fetched 2026-05-04).
axes.variants[navigation] extended + MatSidenav (selector: mat-sidenav) extends MatDrawer with three additional inputs for pinned navigation-rail layouts: `fixedInViewport: boolean` (default false) — positions the sidenav fixed relative to the viewport rather than the container; `fixedTopGap: number` (default 0) — gap in px between the sidenav top edge and the viewport top; `fixedBottomGap: number` (default 0) — gap in px between the sidenav bottom edge and the viewport bottom. MatSidenavContainer / MatSidenavContent mirror the drawer pair. The canonical navigation variant is modelled as a persistent non-modal column with no viewport-pinning details. Material ships fixedInViewport as a first-class input for layouts where the app toolbar is not part of the sidenav container (common Material Design shell pattern). This is a genuine extension beyond the canonical contract. Source: github.com/angular/components blob src/material/sidenav/sidenav.ts (fetched 2026-05-04).
Why this audit reads the way it does

Angular Material Sidenav diverges from the canonical Drawer in four primary ways: 1. Three-node layout compound (<mat-drawer-container> + <mat-drawer> + <mat-drawer-content>) — canonical is a single container node. The split is required to implement mode="side" and mode="push" content-displacement without consumer layout code, but it makes the component a full-page layout primitive rather than a composable overlay. 2. mode input collapses canonical variants — 'over' (modal overlay), 'side' (persistent non-modal), and 'push' (Material-specific content-push) replace the canonical modal / non-modal / navigation variant enum. Push has no canonical equivalent. 3. Inline-axis only — position: 'start' | 'end' covers left/right; top/bottom drawers are out of scope (handled by MatBottomSheet separately). Canonical supports all four viewport edges. 4. No anatomy slots — header, title, close-button, footer, and handle are all omitted; the consumer projects content directly into <mat-drawer>. This is consistent with Angular Material's full-content-projection philosophy but places the entire UX anatomy in consumer hands. Material strength: the mode="side" / mode="push" content-displacement is a genuine capability the canonical Drawer does not model — it covers the persistent sidebar-nav shell pattern that the canon explicitly calls out as bordering on a layout primitive. MatSidenav's fixedInViewport extends this further for pinned navigation rails, a common Material Design shell pattern.

headlessui Dialog (side-anchored composition — no formal Drawer primitive)
import { useState } from 'react';
import { Dialog, DialogPanel, DialogTitle, DialogBackdrop, CloseButton, Transition } from '@headlessui/react';
export function SideDrawer({ isOpen, onClose }) {
return (
<Transition show={isOpen}>
<Dialog onClose={onClose} className="relative z-50">
{/* Backdrop — DialogBackdrop is a named export in v2.1 */}
<DialogBackdrop
className="fixed inset-0 bg-black/40
data-closed:opacity-0 data-enter:duration-200 data-leave:duration-150
transition-opacity"
/>
{/* Side-anchor positioning is purely consumer CSS — no library prop */}
<div className="fixed inset-y-0 inline-end-0 flex">
<DialogPanel
className="relative w-80 bg-white shadow-xl flex flex-col
data-closed:translate-x-full data-enter:duration-300 data-leave:duration-200
transition-transform"
>
<div className="flex items-center justify-between px-4 py-3 border-b">
<DialogTitle as="h2" className="text-lg font-semibold">
Filters
</DialogTitle>
{/* CloseButton auto-wires onClose on the nearest Dialog ancestor */}
<CloseButton className="rounded p-1 hover:bg-gray-100">
<span aria-hidden="true">&times;</span>
<span className="sr-only">Close</span>
</CloseButton>
</div>
<div className="flex-1 overflow-y-auto p-4">
{/* body content */}
</div>
<div className="border-t px-4 py-3 flex justify-end gap-2">
<CloseButton as="button" className="btn-secondary">Cancel</CloseButton>
<button className="btn-primary">Apply</button>
</div>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}

Divergence

From Type → To Rationale
anatomy[container] reshaped Transition + Dialog + DialogPanel composition; side-anchoring via consumer CSS only (e.g. `fixed inset-y-0 inline-end-0` Tailwind utilities or `position: fixed; inset-block: 0; inset-inline-end: 0`) Headless UI ships no Drawer primitive. The canonical container is an edge-anchored panel with a `side` prop driving logical positioning. Headless UI Dialog renders into a portal but applies no positioning itself — DialogPanel is unstyled and centred only when the consumer wraps it in a flex/grid full-viewport positioner. Side-anchoring is achieved purely by consumer CSS on the outer positioning wrapper; the library has no API surface for it. This is the most fundamental divergence: the library does not model Drawer as a distinct concept from Dialog. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
axes.properties[side] omitted No `side` prop (or any equivalent). The canonical `side: inline-start | inline-end | block-start | block-end` axis is entirely absent from Headless UI's API. Consumers must apply logical CSS properties by hand (`inset-inline-start: 0`, `inset-block: 0`, etc.) and manage RTL mirroring themselves. The library provides no mechanism to enforce, document, or validate the side choice. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
axes.variants[non-modal] omitted Headless UI Dialog is always modal: it installs a focus trap inside DialogPanel and returns focus to the trigger on close. There is no `non-modal` variant (canonical `role="region"` with focus allowed to escape). Consumers needing a non-modal side panel must build a custom component with no Dialog involvement — the Dialog primitive cannot be used in a non-modal configuration because the focus trap is non-negotiable. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
axes.variants[navigation] omitted No `navigation` variant. The canonical navigation drawer maps to `role="region"` + a non-modal configuration where the page stays fully interactive. Since Headless UI Dialog is always modal (see non-modal above), the navigation variant cannot be composed from it without abandoning the focus trap. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
anatomy[handle] omitted No drag-handle primitive or `swipeable` API. The canonical `handle` slot exposes a resize/dismiss affordance for swipe-to-dismiss on touch devices. Headless UI provides no pointer-drag lifecycle, velocity tracking, or snap-back animation. Consumers must implement the full swipe gesture from scratch (pointermove, pointerup, velocity threshold, transform spring) with no library scaffolding. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
axes.properties[swipeable] omitted No `swipeable` prop, no drag-to-dismiss velocity contract, and no `dragging` state emitted. The canonical `swipeable: true` wires a gesture handler that tracks pointer position and triggers `dragging → closing` above a release-velocity threshold. None of this scaffolding exists in Headless UI. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
axes.states.data[dragging] omitted The canonical `dragging` data state (entered when a swipe gesture is active) has no equivalent. Headless UI Dialog exposes only `data-open` on Dialog and its sub-components. Intermediate drag states must be tracked entirely in consumer component state. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
events[sideChange] omitted No `onSideChange` callback. The canonical `sideChange` event fires when the responsive auto-flip changes the resolved side (e.g. `inline-end` → `block-end` below `breakpoint.sm`). Because the library has no `side` concept and performs no responsive auto-flip, this event cannot exist. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
events[dismiss] reshaped onClose callback without reason discrimination Canonical `dismiss` carries `{ reason: 'escape' | 'backdrop' | 'closeButton' | 'swipe' }`. Headless UI fires `onClose` for Escape and for outside-panel clicks (when DialogBackdrop is wired). Reason discrimination is absent; consumers wiring `CloseButton` or `DialogBackdrop` must inspect the event source themselves. The Drawer-specific `swipe` reason has no backing mechanism at all. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
motion.reducedMotionFallback omitted Headless UI's Transition component (v2.1) does not implement `prefers-reduced-motion: reduce` automatically. The data-attribute transition system (`data-enter`, `data-leave`, `data-closed`) emits class hooks that consumers apply via Tailwind's `motion-safe:` / `motion-reduce:` variants. The canonical `instant` fallback for reduced-motion is achievable but is entirely a consumer responsibility; the library does not skip or shorten transitions on its own. Source: https://headlessui.com/react/transition (v2.1, 2026-05-04).
axes.properties[size] omitted No `size` prop. The canonical `size: sm | md | lg | full` axis drives container inline-size (left/right drawers) or block-size (top/bottom drawers). DialogPanel is fully unstyled; width/height are consumer CSS. This is consistent with Headless UI's intentional no-styling policy. Source: https://headlessui.com/react/dialog (v2.1, 2026-05-04).
Why this audit reads the way it does

Headless UI React v2.1 ships no Drawer primitive. The closest approximation is a Dialog with consumer-applied side-anchoring CSS, but this mapping is structurally shallow: Dialog is always modal with a focus trap and centred-by-default portal, while the canonical Drawer is edge-anchored, can be non-modal, and supports swipe-to-dismiss with a velocity-driven gesture API. The divergences split into three tiers: 1. Missing concept entirely — no `side` prop, no `non-modal` variant, no `navigation` variant, no `swipeable` prop, no drag-handle slot, no `dragging` state, no `sideChange` event. These cannot be composed from Dialog primitives without significant consumer-authored infrastructure that duplicates what a Drawer primitive would provide. 2. Reshaped event model — `onClose` replaces the canonical typed `dismiss({ reason })` contract; reason discrimination is manual. 3. Consistent with the library's unstyled-primitive philosophy — `size`, motion timing, and RTL logical positioning are all consumer CSS. This tier matches the same divergences found in the headlessui/modal audit and is expected given Headless UI's design intent. Consumers building a Drawer on top of Headless UI Dialog should treat the Dialog as providing only the ARIA role, focus trap, Escape handling, and portal mount. Everything that makes a Drawer distinct from a centred Modal — edge anchor, side axis, non-modal mode, swipe gesture — must be authored by the consumer.

vaul Drawer
import { Drawer } from 'vaul';
export function FilterDrawer({ open, onOpenChange }) {
return (
<Drawer.Root
open={open}
onOpenChange={onOpenChange}
snapPoints={[0.5, 1]}
fadeFromIndex={1}
direction="bottom"
dismissible
shouldScaleBackground
>
<Drawer.Trigger asChild>
<button>Open filters</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 flex flex-col rounded-t-[10px] bg-white">
<Drawer.Handle />
<div className="p-4 flex-1 overflow-y-auto">
<Drawer.Title className="text-lg font-semibold">Filters</Drawer.Title>
<Drawer.Description className="sr-only">
Adjust search filters
</Drawer.Description>
{/* filter controls */}
</div>
<div className="p-4 border-t">
<Drawer.Close asChild>
<button>Apply</button>
</Drawer.Close>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
}

Divergence

From Type → To Rationale
anatomy[backdrop] renamed Drawer.Overlay Identical role — full-viewport scrim that intercepts pointer events in modal mode. Vaul adopts the Radix-Dialog vocabulary ("Overlay") rather than the canonical "backdrop". Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
anatomy[container] reshaped Drawer.Root + Drawer.Portal + Drawer.Content Vaul is built on top of Radix Dialog and mirrors its three-part split: Root owns controlled-state and gesture wiring, Portal owns DOM relocation, Content owns the rendered surface and the ARIA dialog role. The canonical "container" (one slot with a focus trap) maps to the intersection of all three. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
anatomy[header] omitted Vaul ships no header sub-component. Drawer.Title and Drawer.Handle are placed directly inside Drawer.Content without a wrapping region; the canonical header layout (title + close button side by side) is consumer-composed with a plain div. Source: https://vaul.emilkowal.ski/getting-started (fetchedAt 2026-05-04)
anatomy[title] renamed Drawer.Title One-to-one functional match. Vaul re-exports Radix Dialog.Title, which renders an `<h2>` and auto-wires `aria-labelledby` on the Content element. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
anatomy[close-button] renamed Drawer.Close Same role. Vaul re-exports Radix Dialog.Close; consumers pass their own button via `asChild`. The accessible name is the consumer's responsibility, not Vaul's. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
anatomy[body] reshaped Drawer.Description plus free children inside Drawer.Content Vaul re-exports Radix Dialog.Description as Drawer.Description for the `aria-describedby` anchor; arbitrary body content is free children of Drawer.Content. For non-prose bodies (filter controls, forms) Description is typically visually hidden while still providing the accessible description. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
anatomy[footer] omitted Vaul ships no footer sub-component. Action buttons are free children of Drawer.Content, typically in a consumer-composed div with a border-top. Positioning and spacing are entirely consumer CSS. Source: https://vaul.emilkowal.ski/getting-started (fetchedAt 2026-05-04)
anatomy[handle] extended + Vaul ships Drawer.Handle as a first-class sub-component — a pill-shaped drag affordance rendered inside Drawer.Content. When handleOnly is true on Drawer.Root, dragging is restricted to the Handle element only; the rest of Content does not respond to drag input. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04) The canonical handle slot is optional-decorative with no drag-target constraints. Vaul promotes Handle to a named sub-component and adds handleOnly mode, which changes the drag-target semantics beyond what the canonical anatomy expresses.
axes.properties[side] renamed direction — prop on Drawer.Root; values: 'top' | 'right' | 'bottom' | 'left' Canonical uses logical-property values (`inline-start`, `inline-end`, `block-start`, `block-end`). Vaul uses physical direction strings (`top`, `right`, `bottom`, `left`), reflecting its mobile-first bottom-sheet origin. RTL callers must flip `left`/`right` manually; no automatic logical-property mirroring is provided. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
axes.properties[size] omitted Vaul ships no size prop. Drawer height/width is entirely consumer CSS on Drawer.Content (or expressed as snap-point fractions). The canonical `sm | md | lg | full` scale is a design-system convention above the primitive. Source: https://vaul.emilkowal.ski/getting-started (fetchedAt 2026-05-04)
axes.properties[swipeable] reshaped dismissible (boolean) + handleOnly (boolean) + snapPoints (array) + closeThreshold (number) + snapToSequentialPoint (boolean) Canonical expresses drag-to-dismiss as a single `swipeable` boolean. Vaul granularises this across several cooperating props: `dismissible` covers swipe-to-close AND Escape AND overlay-click as one switch; `handleOnly` restricts the drag hit-target; `snapPoints` introduces intermediate rest positions; `closeThreshold` sets the fraction of the drawer that must be dragged before it dismisses; and `snapToSequentialPoint` controls velocity-skip behaviour across snap points. These are not separate features — they are all elaborations of the same swipe-gesture contract. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx and https://vaul.emilkowal.ski/snap-points (fetchedAt 2026-05-04)
axes.variants[non-modal] reshaped modal={false} on Drawer.Root Canonical expresses modality as a variant enum (`modal`, `non-modal`, `navigation`). Vaul uses a `modal` boolean prop on Drawer.Root (defaults `true`). Setting `modal={false}` removes the Overlay and allows background interaction, corresponding to the canonical non-modal variant; the navigation variant has no direct Vaul equivalent and must be composed by the consumer. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
axes.properties[dismissible] reshaped dismissible (boolean on Drawer.Root) collapses Escape + overlay-click + swipe-to-dismiss into a single flag; individual paths are not separately suppressible Canonical treats `dismissible` as a single switch that maps to three independent dismiss paths (Escape, backdrop click, swipe). Vaul's `dismissible` prop is also a single switch but collapses all three paths together — there is no way to allow Escape while disabling swipe, for example. Consumers needing mixed suppress/allow must intercept events at the Radix Dialog layer that Vaul wraps. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04)
events[openChange] extended + Vaul adds onDrag(event, percentageDragged) and onRelease(event, open) callbacks alongside onOpenChange. These expose the in-progress drag position as a 0–1 fraction of total travel and the release decision, enabling consumers to mirror the canonical dragging state in their own UI without polling. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx (fetchedAt 2026-05-04) The canonical events surface only openChange and dismiss at the settled edges of the state graph. Vaul exposes mid-gesture telemetry (onDrag, onRelease) that lets consumers drive parallel animations or progress indicators during the drag — a capability the canonical event surface does not model because it is implementation-specific to gesture-driven drawers.
axes.states.data[dragging] extended + Beyond the canonical dragging state, Vaul surfaces activeSnapPoint as a controlled value on Drawer.Root. Consumers can read and set which snap point is currently active via snapPoints array plus setActiveSnapPoint callback, creating discrete multi-position open states (e.g. half-open, fully open) that the canonical binary open/closed model does not address. fadeFromIndex controls at which snap index the Overlay begins to fade in. Source: https://raw.githubusercontent.com/emilkowalski/vaul/main/src/index.tsx and https://vaul.emilkowal.ski/snap-points (fetchedAt 2026-05-04) The canonical state graph treats open as a single position. Vaul's snap-point system introduces a controlled intermediate-open axis that is core to its mobile bottom-sheet use case — partial reveal at one snap point, full reveal at another. This is a structural addition to the state model, not a renaming of an existing state.
Why this audit reads the way it does

Vaul is a React-only, bottom-sheet-first drawer primitive built on top of Radix Dialog. It covers the canonical anatomy faithfully for modal use — Overlay, Content, Title, Description, Close, Handle all map cleanly — but deliberately narrows the scope: no header/footer sub-components, no logical property direction values (physical strings only), no size scale, and no navigation variant. The most significant additions over canon are snap-points (multi-position rest states), shouldScaleBackground (parallax body-scale during drag), and the granular drag-lifecycle callbacks (onDrag, onRelease). The repo is currently unmaintained (maintainer notice on GitHub as of 2026-05-04); version-level pinning is not possible from the README.

Designer

Figma anatomy

Slot Figma type Hint
backdrop frame Full-bleed frame with semi-transparent fill; conditional on modal variant
container frame Edge-anchored auto-layout frame; size variant drives inline-size for left/right, block-size for top/bottom
header from header-bar frame Auto-layout horizontal frame at the inline-start edge of the container
title from header-bar text Heading text style; bound to a component property for content
close-button from close-button instance Icon button instance, "close" variant, inline-end position
close-icon from close-button instance × glyph or close-icon symbol, sized to the button's content area
close-label from close-button text Visually-hidden, localized "Close" (or context-specific equivalent)
body frame Auto-layout vertical frame; min-block-size drives "comfortable" density
footer frame Auto-layout horizontal frame; right-aligned actions in LTR
primary-action from action-group instance Button instance, primary variant, inline-end position
secondary-action from action-group instance Button instance, secondary variant
handle rectangle 4×40px pill at the leading edge; visibility bound to "swipeable" property
Designer

Token usage per slot

backdrop
color
  • backgroundcolor.surface.scrim
container
spacing
  • paddingspacing.comfortable
radius
  • cornerradius.lg
color
  • backgroundcolor.surface.raised
  • bordercolor.border.subtle
elevation
  • shadowelevation.overlay
header
spacing
  • paddingspacing.compact
  • gapspacing.compact
title
color
  • foregroundcolor.text.primary
typography
  • sizetext.lg
  • weightweight.semibold
  • lineHeightleading.tight
close-button
spacing
  • paddingspacing.tight
radius
  • cornerradius.sm
color
  • foregroundcolor.text.muted
  • ringcolor.border.focus
body
spacing
  • paddingspacing.comfortable
color
  • foregroundcolor.text.primary
typography
  • sizetext.md
  • lineHeightleading.normal
footer
spacing
  • paddingspacing.compact
  • gapspacing.compact
primary-action
spacing
  • gapspacing.tight
handle
radius
  • cornerradius.pill
color
  • backgroundcolor.border.subtle
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps modal / non-modal / navigation. Drives ARIA role.
SideEnumsidestart / end / top / bottom. Encoded as Variant for preview-time positioning review.
SizeEnumsizesm / md / lg / full. Drives container inline-size (left/right) or block-size (top/bottom).
DismissibleBooleandismissibleToggles close-button visibility plus Escape and backdrop-click handling. Some host libraries combine the three; canonical treats them as one switch.
SwipeableBooleanswipeableToggles handle visibility plus swipe-gesture wiring. Generally true on mobile breakpoints, false on desktop.
Has BackdropBooleanbackdropDerived in canon from `variant: modal` (true) vs `non-modal` (false); Figma may expose it as a separate Boolean for design preview.
TitleTexttitle
Has HeaderBooleanheader
BodySlotbodySwaps the body content slot (form, prose, list, custom layout).
Has FooterBooleanfooter
FooterSlotfooter
Designer

Motion

TransitionDuration token
openmotion.duration.base
closemotion.duration.base
backdropmotion.duration.fast
dragSnapmotion.duration.fast
Easing
motion.easing.decelerate
Reduced motion
Instant (jump cut)
Designer

Responsive behaviour

BreakpointChange
breakpoint.smAt and below, all drawer variants render as the `bottom` side with `size: full` regardless of authored `side` and `size` properties — narrow viewports do not afford left/right edge anchoring without crowding the underlying content. Navigation drawers may further degrade to a fullscreen overlay (`size: full`, suppressed handle).
breakpoint.mdAbove this width, all variants render as authored. The `navigation` variant typically pins as a permanent column on desktop (non-modal, `size: md` minimum); below `md` it collapses back to the modal-with-trigger pattern.
Both

Internationalisation

RTL · mirroring

The `side` property is logical, not physical: `start` slides from the inline-start edge (visual right in RTL), `end` from the inline-end (visual left in RTL). Implemented via `inset-inline-start` / `inset-inline-end`, not `left` / `right`. Top and bottom sides are direction-neutral. Close-button positioning inside the header follows the existing logical pattern (close on inline-end). Swipe-to-dismiss gesture direction reverses for `start` and `end` sides under RTL.

Text expansion

Drawer width is sized in CSS (`size: sm | md | lg | full`); long titles and labels grow naturally within the inline-size. For `size: sm`, German and Russian titles risk wrap or truncation — `size: md` is the safer canonical default in long-text locales. Body content scrolls; footer buttons may wrap to two rows under heavy expansion.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

modal non-modal navigation

Properties

The same component, parameterised.

PropertyType
side inline-start | inline-end | block-start | block-end
size sm | md | lg | full
dismissible boolean
swipeable boolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
focus-visiblehover
data
closedopeningopenclosingdragging
Both

State transitions

FromToTrigger
closedopeningUser activates the trigger that owns the drawer (button click, link activation, programmatic `open()`). For modal drawers the previously-focused element is captured for restoration on close; for non-modal, focus does not move automatically.
openingopenThe slide-in animation completes (or, under `prefers-reduced-motion: reduce`, immediately after `closed → opening`). Modal: focus moves into the drawer, focus trap engages, siblings become `inert`. Non-modal: drawer is ready, focus stays where it was.
openclosingUser dismisses via the close button, Escape (when `dismissible: true`), backdrop click (modal + dismissible), or swipe gesture (when `swipeable: true`); or the primary action commits and programmatically requests close.
closingclosedThe slide-out animation completes (or immediately under reduced motion). Modal: drawer is removed from the AT tree, `inert` is released, focus restores to the captured trigger. Non-modal: drawer is removed; focus stays where it was.
opendraggingUser initiates a swipe gesture on the handle or container edge (`swipeable: true`). The drawer follows the pointer position with reduced damping; release-velocity above threshold triggers `dragging → closing`, below resets to `dragging → open`.
draggingopenUser releases the swipe with insufficient velocity / distance to dismiss; drawer animates back to its open position.
draggingclosingUser releases the swipe with sufficient velocity / distance to dismiss; drawer animates the rest of the way out via the standard close path.
Both

Figma↔Code mismatches

  1. 01
    Figma

    A drawer drawn as a static side panel that's just always there

    Code

    A portal-mounted panel with focus trap, escape handling, slide animation, and `inert` toggling

    Consequence

    The Figma artifact captures the visual end-state but encodes none of the open/close lifecycle, the modality, or the focus contract. Designers approximating the canonical drawer from the Figma file may not realise modal drawers need `inert`, escape handling, and focus trap; developers may ship something that looks like a drawer but escapes attention semantics.

    Correct

    Document the open/close lifecycle and modality contract in the canonical reference. The Figma file captures visual states (closed, opening, open, dragging); the canonical reference and a11y notes document the portal mount, focus trap, escape, and `inert` for modal drawers.

  2. 02
    Figma

    Modal drawer and non-modal drawer drawn as one variant set

    Code

    Two distinct accessibility contracts (focus trap + inert vs none) driven by the variant prop

    Consequence

    Designers may pick "drawer" without realising the modality choice determines whether keyboard users can tab past the drawer; the canonical contract for `role` (`dialog` vs `region`) differs between the two. Developers shipping non-modal-styled-as-modal lock keyboard focus inside a panel that looks dismissable just by clicking outside.

    Correct

    Treat modal vs non-modal as a structural variant (separate ARIA role, separate keyboard contract). Designers and developers both consult the canon to confirm which variant they're using; the Figma component carries a Variant property that surfaces both.

  3. 03
    Figma

    Swipe-to-dismiss drawn as a static "drag handle" with no animation

    Code

    A pointer-event-driven gesture handler tracking velocity and distance, with kinematic close-or-snap-back

    Consequence

    Designers see a static handle and may assume it's just a visual affordance; developers either ship without swipe support (touch-input feels broken) or invent the gesture interaction from scratch. Animation curves and dismiss thresholds are reinvented per implementation.

    Correct

    Document swipe-to-dismiss as a `swipeable: true` property with explicit canonical states (`dragging`) and transitions. Specify threshold conventions in the canonical reference (e.g. "release velocity > 800px/s OR distance > 30% closes").

  4. 04
    Figma

    Side `inline-start` vs `inline-end` drawn as two separate components

    Code

    A single component with a `side` property toggling logical positioning

    Consequence

    Designers and developers count drawers differently — designers see four components (left, right, top, bottom drawers); developers see one with a property. Variant explosion (4 sides × 3 modal-modes × 4 sizes = 48 variants) makes the Figma file unmaintainable.

    Correct

    Model `side` as a property, not as separate components. Use Figma's Variant property type for the four side values; the anatomy is shared across all four.

Both

Contracts

Vocabulary drift

Polaris
Modal
Polaris's "Modal" covers both centred dialogs and edge-anchored panels under one component; the canonical Drawer is the edge-anchored half.
Carbon
Side panel
Material 3
Navigation drawer / Side sheet
Material 3 ships two separate components for what the canonical Drawer covers as one (with `variant: navigation` vs other variants).
vaul
Drawer
vaul's "Drawer" is specifically the bottom-sheet variant; the canonical Drawer covers all four sides plus modal and non-modal modes.
Designer

Common mistakes

Blocker

#drawer-no-focus-trap-on-modal

Modal drawer with no focus trap

Problem

The drawer is styled to feel modal (backdrop, scrim) but pressing Tab from the last focusable inside the drawer moves focus to the next element in the page beneath the backdrop. Visually obscured but technically focused.

Fix

Implement a focus trap when `modal: true`. Modern primitives (Radix Dialog, React Aria Modal, vaul) include this; never roll your own without `inert` on the rest of the document.

Blocker

#drawer-no-focus-restore

Focus is lost after the drawer closes

Problem

After dismissal, focus lands on `<body>`. Keyboard and screen-reader users have to re-orient from the top of the page.

Fix

Capture the previously-focused element on open (modal variants). Restore focus to it on close. If the trigger no longer exists, focus a stable landmark (page heading, parent toolbar).

Blocker

#drawer-swipe-without-keyboard-equivalent

Swipe-to-dismiss with no keyboard equivalent

Problem

Mobile drawers are dismissable via swipe but keyboard users on desktop have no equivalent. Either the drawer cannot be closed from the keyboard at all, or the close button is missing on the assumption "users will swipe".

Fix

Every dismissible drawer carries a visible close-button slot reachable by keyboard. Escape dismisses when `dismissible: true` regardless of input modality. Swipe is an additive convenience, not a replacement.

Major

#drawer-side-not-rtl-aware

Side `start` hard-coded to `left`

Problem

The drawer slides from the left in LTR — and also in RTL, breaking RTL users' expectation that "start" means visual right. Implementations using `left: 0` or `transform: translateX(-100%)` hard-code direction.

Fix

Use logical properties: `inset-inline-start: 0`, `transform: translateX(calc(-1 * 100%))` only when paired with `:dir(rtl)` overrides, or better, use logical-property-aware transforms via a CSS variable. The drawer's `side: start` slides from inline-start in any direction.

Major

#drawer-stacking-with-modal

Drawer opened on top of an existing Modal

Problem

Both surfaces install their own focus traps and `inert` handling. The two systems fight: focus may bounce between the modal and the drawer; assistive tech reads stale content from the now-inert modal beneath.

Fix

Forbid stacking by canon: a Drawer and a Modal cannot be open simultaneously. Either dismiss the modal before opening the drawer, or model the drawer's content as a step inside the modal flow. Stacking is a redesign signal, not a layering concern.

Accessibility hints
Slot Accessibility hint
backdrop Backdrop is presentational; do not put `role` on it. Clicking the backdrop dismisses the drawer when `dismissible: true`. Keyboard users always have an explicit close affordance because they cannot click the backdrop.
container For modal drawers, apply `role="dialog"` and `aria-modal="true"`, with focus trap and `inert` on siblings. For non-modal drawers, `role="region"` with `aria-labelledby` pointing at the title is the canonical choice — focus may escape the drawer naturally. Always label via `aria-labelledby` regardless of modality.
header Header is a layout region (heading-region semantic), not a heading. Heading semantics live on the title element inside. Do not give the wrapper its own role attribute or heading level — APG dialog and tooltip patterns place the heading on the title element only.
title Use a real heading element of an appropriate level. The drawer's container references this element via aria-labelledby. Hidden titles (visually-hidden but accessible) are valid for drawers that do not visually display a title.
close-button Real <button type="button"> with an accessible name. Pick one of: aria-label="Close" OR a visually-hidden <span> child — declaring both is duplicative announcement. The Escape key must trigger the same action when the host surface is dismissible.
close-icon aria-hidden="true" on the SVG. Never give the icon its own accessible name (title attribute, <title> child, or aria-label) — that double-announces alongside the host's name.
close-label Visually-hidden <span> describing the drawer's content ("Close filters", "Close navigation"), OR aria-label on the host with the same text. Bare "Close" is acceptable for a single-purpose drawer, but context-scoped is the safer canonical choice.
body Body retains its native semantics (forms stay forms, lists stay lists). For scroll-on-overflow, ensure the scrollable region is keyboard-reachable (`tabindex="0"` on the scroll container) so keyboard users can scroll without an interactive child.
footer Container for commit/cancel actions. The button instances live inside the action-group sub-anatomy (primary-action / secondary-action). Document the default focus target (usually the primary commit button); destructive commits should default focus to the cancel action.
primary-action Real button with accessible name. First in DOM order; visual position is inline-end via logical properties so RTL mirroring is automatic.
secondary-action Real button with accessible name. Second in DOM order; visually inline-start of the primary action in LTR.
handle Decorative when only providing a visual cue. If the handle also accepts pointer drag for resize, expose it as `role="separator"` with `aria-orientation` and ARIA range values, and provide keyboard equivalents (ArrowKeys to resize).