Dev 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.
Implementations
How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.
MatDrawer / MatSidenav (from @angular/material/sidenav) 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.
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">×</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.
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.
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
backdrop | backdrop | presentational-overlay |
container | container | dialog-or-region |
header
from header-bar | header | heading-region |
title
from header-bar | title | heading |
close-button
from close-button | close-button | button |
close-icon
from close-button | close-icon | img-decorative |
close-label
from close-button | close-label | visually-hidden |
body | body | prose-or-form-or-list |
footer | footer | button-group |
primary-action
from action-group | primary-action | button |
secondary-action
from action-group | secondary-action | button |
handle | handle | presentational-or-resize-affordance |
Variants, properties, states
Variants
Structurally different versions of the component.
modal non-modal navigation Properties
The same component, parameterised.
| Property | Type |
|---|---|
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).
| Kind | States |
|---|---|
interactive | focus-visiblehover |
data | closedopeningopenclosingdragging |
State transitions
| From | To | Trigger |
|---|---|---|
closed | opening | User 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. |
opening | open | The 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. |
open | closing | User 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. |
closing | closed | The 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. |
open | dragging | User 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`. |
dragging | open | User releases the swipe with insufficient velocity / distance to dismiss; drawer animates back to its open position. |
dragging | closing | User releases the swipe with sufficient velocity / distance to dismiss; drawer animates the rest of the way out via the standard close path. |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-drawer>` host with named slots for `header`, `body`, `footer`, `handle`; modal variant uses native `<dialog>` internally with custom positioning, non-modal uses a positioned `<aside>` | attributes (`variant="modal"`, `side="inline-end"`, `size="md"`, `dismissible`, `swipeable`); `data-state="open|closed|opening|closing|dragging"` for CSS transitions |
| React | portal-based primitives (Radix has no first-party Drawer; vaul or React Aria Drawer-via-Modal compose the slots as children) compositing the slots as named children | props with class-variance-authority for variant/side/size; `data-state` attribute for transition states; `onOpenChange` / `onDismiss` controlled-pattern callbacks |
| Angular (signals) | Angular CDK Overlay + custom positioning strategy (top/bottom/start/end attached); content projection for header / body / footer slots | input<'modal' | 'non-modal' | 'navigation'>(), input<'inline-start' | 'inline-end' | 'block-start' | 'block-end'>(); host bindings drive `[attr.role]` (dialog vs region) |
| Vue | Headless UI does not yet ship a Drawer (as of 2026-04); third-party (vue-final-modal, custom composable around Teleport) provides the portal + focus trap; named slots for header/body/footer | defineProps with literal-union types; `:data-state` for transition states; emits `update:open` / `dismiss` |
Events
openChangedismisssideChangeoptional
Form integration
- name attribute
- Drawer is a container, not a form control — it has no `name` attribute. Forms hosted inside the drawer carry their own `name` attributes on their fields. Native `<dialog>` cooperates with `<form method="dialog">` to submit-and-close in a single user action; custom-implementation drawers re-implement this contract.
- FormData serialization
- Forms inside the drawer submit normally via their own `<form>` element. The drawer itself contributes nothing to FormData. Filter-style drawers commonly serialize their internal form on every change rather than on submit, persisting filter state to the URL or app state.
- form.reset()
- Forms inside the drawer respond to `form.reset()` independently of the drawer's open/closed state. Closing via Escape, backdrop, or swipe does not reset the form. Filter drawers often expose a "Reset filters" action that calls `form.reset()` plus clears application state.
- HTML5 validation
- Forms inside the drawer use HTML5 validation as normal. Validation failures focus the first invalid field — for modal drawers focus stays inside the drawer because of the focus trap; for non-modal drawers the focus also stays naturally because the field is reachable. Avoid auto-closing the drawer on submit failure.
Performance thresholds
stackDepthopen-drawer-count≥1drawersAvoid stacking drawers; the canonical maximum is one open drawer at a time, and a drawer cannot stack on top of a Modal either (see mistake `drawer-stacking-with-modal`). Stacked drawers fight for focus traps and `inert` handling. Patterns that need "drawer-on-drawer" should be redesigned as a sequence or as nested in-drawer disclosure.
swipeFrameBudgetdrag-frame-time≥16msSwipe-to-dismiss feels broken when the drawer position update misses the 16ms (60fps) frame budget. Position updates must use transform (compositor thread), never `inset-inline-start` changes (layout thread). Pin transform-only animations and profile against this budget on low-tier mobile.
Accessibility
| 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). |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Modal: focus moves to the next focusable inside the drawer; cycles back to the first after the last (focus trap). Non-modal: focus moves to the next focusable in the document — drawer content first, then siblings; focus may escape the drawer naturally. |
Shift+Tab | Modal: focus moves to the previous focusable inside the drawer; cycles to the last after the first. Non-modal: focus moves to the previous document focusable. |
Escape (when dismissible is true) | Closes the drawer along the canonical `open → closing → closed` path. Modal: focus restores to the trigger. Non-modal: focus stays where it was. |
ArrowLeft / ArrowRight (with handle focused, swipeable resize) | Resizes the drawer along the inline axis when the handle exposes `role="separator"`. Step size matches the keyboard increment convention (~24px). |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Modal drawer opens | SR announces the drawer's title (via `aria-labelledby`) followed by "dialog" — e.g. "Filters, dialog". Page context is silenced via `inert` on siblings. |
| Non-modal drawer opens | SR announces the title followed by "region" — e.g. "Recent activity, region". Page context remains in the AT tree; focus does not move automatically. |
| Drawer closes | Focus returns to the trigger (modal) or stays in place (non-modal). The trigger's accessible name is re-announced. No automatic announcement is required for the close itself. |
| Swipe-to-dismiss in progress | SR users do not perform swipe dismissals (touch + visual feedback only). The drawer's `dragging` state is not announced; the resulting close event triggers the standard close announcement. |
axe-core rules to assert
aria-dialog-namearia-required-attraria-hidden-focuscolor-contrastfocus-order-semanticslandmark-uniqueregion
Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe:
/api/components/drawer/a11y-fixture.json
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.
Common mistakes
#drawer-no-focus-trap-on-modal
Modal drawer with no focus trap
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.
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.
#drawer-no-focus-restore
Focus is lost after the drawer closes
After dismissal, focus lands on `<body>`. Keyboard and screen-reader users have to re-orient from the top of the page.
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).
#drawer-swipe-without-keyboard-equivalent
Swipe-to-dismiss with no keyboard equivalent
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".
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.
#drawer-side-not-rtl-aware
Side `start` hard-coded to `left`
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.
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.
#drawer-stacking-with-modal
Drawer opened on top of an existing Modal
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.
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.
Figma↔Code mismatches
- 01 Figma
A drawer drawn as a static side panel that's just always there
CodeA portal-mounted panel with focus trap, escape handling, slide animation, and `inert` toggling
ConsequenceThe 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.
CorrectDocument 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.
- 02 Figma
Modal drawer and non-modal drawer drawn as one variant set
CodeTwo distinct accessibility contracts (focus trap + inert vs none) driven by the variant prop
ConsequenceDesigners 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.
CorrectTreat 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.
- 03 Figma
Swipe-to-dismiss drawn as a static "drag handle" with no animation
CodeA pointer-event-driven gesture handler tracking velocity and distance, with kinematic close-or-snap-back
ConsequenceDesigners 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.
CorrectDocument 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").
- 04 Figma
Side `inline-start` vs `inline-end` drawn as two separate components
CodeA single component with a `side` property toggling logical positioning
ConsequenceDesigners 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.
CorrectModel `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.