` | The sheet (`role="dialog"`); hosts the drag gesture + focus trap |
+| `Drawer.Handle` | `
` | Visual drag grip; the hit-test target when `handleOnly` |
+| `Drawer.Title` | `
` | Heading, wired to `aria-labelledby` |
+| `Drawer.Description` | ` ` | Description, wired to `aria-describedby` |
+| `Drawer.Close` | `` | Closes the drawer on click |
+
+## Props
+
+### `Drawer.Root`
+
+| Prop | Type | Default | Description |
+| ------------------------- | ------------------------- | ------- | ----------------------------------------------- |
+| `open` | `boolean` | — | Controlled open state |
+| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
+| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
+| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
+| `dismissible` | `boolean` | `true` | Allow drag / outside-press / Escape to close |
+| `handleOnly` | `boolean` | `false` | Only `Drawer.Handle` starts a drag |
+| `handle` | `DrawerHandle` | — | Shared handle for a detached trigger |
+| `snapPoints` | `number[]` | — | Ascending viewport fractions (0..1) to rest at |
+| `activeSnapPoint` | `number` | — | Controlled active snap index |
+| `defaultActiveSnapPoint` | `number` | last | Uncontrolled initial active snap index |
+| `onActiveSnapPointChange` | `(index: number) => void` | — | Called when the active snap index changes |
+| `repositionInputs` | `boolean` | `true` | Keep a focused field above the virtual keyboard |
+| `autoFocus` | `boolean` | `false` | Move focus into the sheet on open (see notes) |
+
+### `Drawer.Trigger`
+
+| Prop | Type | Description |
+| -------- | -------------- | ---------------------------------------------------------------- |
+| `handle` | `DrawerHandle` | Drive a detached handle instead of the surrounding `Drawer.Root` |
+
+### `Drawer.Viewport`
+
+| Prop | Type | Default | Description |
+| ------------ | --------- | ------- | ------------------------------- |
+| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |
+
+Other parts accept standard HTML attributes plus the `render` prop.
+
+## Keyboard
+
+| Key | Action |
+| -------- | ---------------------------------------------------------------- |
+| `Escape` | Closes the drawer (closes a nested child overlay first, if open) |
+| `Tab` | Cycles focus within the sheet (modal mode) |
+
+## Styling API
+
+The headless parts emit raw inputs only — the styled layer composes them. The high-frequency vars
+(`swipe-movement-y`, `snap-point-offset`, `swipe-progress`) are registered as non-inheriting custom
+properties via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` is unavailable).
+
+### CSS custom properties (on `Drawer.Popup`)
+
+| Variable | Written by | Meaning |
+| ---------------------------------- | ------------- | ------------------------------------------------------------------------------------- |
+| `--cl-drawer-swipe-movement-y` | drag engine | px live drag delta on the Y axis (0 at rest) |
+| `--cl-drawer-swipe-progress` | drag engine | 0..1 dismiss progress (drives backdrop fade) |
+| `--cl-drawer-snap-point-offset` | snap layer | px resting translateY of the active snap point |
+| `--cl-drawer-swipe-strength` | drag engine | 0.1..1 from release velocity (scales exit speed) |
+| `--cl-drawer-nested-drawers` | nesting layer | count of open nested children |
+| `--cl-drawer-nested-drag-progress` | nesting layer | 0..1 dismiss progress of the dragged nested child (drives the parent's live scale-in) |
+
+### Data attributes
+
+| Attribute | Applies to | Meaning |
+| ------------------------------------------------- | ---------------------------------- | ------------------------------- |
+| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
+| `data-cl-starting-style` / `data-cl-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase |
+| `data-cl-swiping` | Popup, Backdrop | A drag is in progress |
+| `data-cl-snap` | Popup | Active snap index |
+| `data-cl-expanded` | Popup | Resting at the full-height snap |
+| `data-cl-nested` | Popup | This drawer is itself nested |
+| `data-cl-nested-drawer-open` | Popup | A nested child is open |
+| `data-cl-nested-drawer-swiping` | Popup | A nested child is being dragged |
+| `data-cl-drawer-handle` | Handle | Grip / `handleOnly` hit-test |
+| `data-cl-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging |
+
+Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.
+
+### Nested drawer animation (styled-layer recipe)
+
+Everything a styled layer needs for vaul-style nested scaling is emitted; it owns only the
+displacement constant. `--cl-drawer-nested-drag-progress` is `0` at the scaled-back rest and `1` at
+full size, so the same var drives both the enter/exit scale-back and the live drag coupling:
+
+```css
+[data-cl-slot='drawer-popup'] {
+ --rest-scale: calc((100vw - 16px) / 100vw); /* vaul's NESTED_DISPLACEMENT = 16px */
+ transform-origin: center top;
+ transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+/* A child is open: scale = lerp(rest-scale, 1, progress); translateY = lerp(-16px, 0, progress). */
+[data-cl-slot='drawer-popup'][data-cl-nested-drawer-open] {
+ --p: var(--cl-drawer-nested-drag-progress);
+ transform: translateY(calc(-16px * (1 - var(--p))))
+ scale(calc(var(--rest-scale) + (1 - var(--rest-scale)) * var(--p)));
+}
+
+/* While the child drags, follow the finger 1:1 (no animation). */
+[data-cl-slot='drawer-popup'][data-cl-nested-drawer-swiping] {
+ transition: none;
+}
+```
+
+Lifecycle of the driving signals (all handled by the headless layer):
+
+- **Child opens** → `data-cl-nested-drawer-open` is set and `--cl-drawer-nested-drag-progress` is
+ reset to `0`, so the parent animates from full to the scaled-back rest.
+- **Child drags** → `data-cl-nested-drawer-swiping` is set and progress tracks the drag `0..1`, so the
+ parent follows the finger back toward full.
+- **Child releases** → swiping clears and progress settles to `0` (child stays open) or `1` (child
+ dismisses). The dismiss target matches the open-count dropping, so the scale animates in one
+ direction with no backward flicker.
+- **Multiple levels** → `--cl-drawer-nested-drawers` carries the open-child count for stacking depth.
+
+## Important Notes
+
+- **`autoFocus` defaults to `false`** (unlike `Dialog`). Opening on touch should not move focus to an
+ input and summon the keyboard, so focus goes to the sheet container by default. Set `autoFocus` to
+ move focus to the first field.
+- **`Drawer.Handle` is presentational** (no ARIA role). Keyboard users dismiss via `Escape` /
+ `Drawer.Close`; add your own `role` / `aria-*` (via props or `render`) if you need it announced.
+- **Drag never hijacks form controls.** `shouldDrag` excludes ``, native `range` inputs,
+ `[role="slider"]` thumbs, `[data-cl-drawer-no-drag]` subtrees, active text selections (DOM,
+ `contenteditable`, and focused `input` / `textarea` selection handles), and inner content scrolled
+ away from the top. Use `data-cl-drawer-no-drag` for any other custom draggable.
+- **Nested overlays don't dismiss the drawer.** A `Select` / `Autocomplete` / `Menu` opened inside the
+ sheet joins the same `FloatingTree`, so a press in its portal counts as inside.
+- **Nested drawers** stack automatically (shared `FloatingTree`) and drive the parent's scale-back
+ through the signals above. The scale-back uses a fixed displacement (vaul's model), not the child's
+ height. See the styled-layer recipe under **Styling API → Nested drawer animation** for the full
+ contract; the release always settles in one direction so the styled scale never flickers.
+
+## Out of scope (follow-ups)
+
+- Directions other than bottom (top / left / right).
+- Full iOS keyboard hardening (the pre-focus `translateY` trick and a `focus` override) plus
+ snap-point-aware keyboard offsets and flick-focus suppression.
+- Resize-driven recomputation of snap offsets.
+- Per-trigger payloads on detached handles (`createDrawerHandle()` + a render-prop `Drawer.Root`).
+- `onActiveSnapPointChange` event details (the change `reason`, e.g. close-press vs. drag).
+- The mosaic styled layer and a playground.
+
+## ARIA
+
+- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description).
+- Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls` (in-tree).
diff --git a/packages/headless/src/primitives/drawer/constants.ts b/packages/headless/src/primitives/drawer/constants.ts
new file mode 100644
index 00000000000..72e9bfe3333
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/constants.ts
@@ -0,0 +1,27 @@
+/**
+ * Tunable thresholds for the drag/snap engine. Values ported from vaul
+ * (`src/constants.ts`) and base-ui (`useSwipeDismiss`). Visual timing
+ * (durations, easing) lives in CSS, never here — see the styled-layer contract
+ * in the README.
+ */
+
+/** Fraction of the popup height past which a release closes the drawer. (vaul) */
+export const CLOSE_THRESHOLD = 0.25;
+
+/** px/ms release velocity above which a downward flick closes regardless of distance. (vaul) */
+export const VELOCITY_THRESHOLD = 0.4;
+
+/** px/ms release velocity above which a snap is skipped on release. (vaul) */
+export const SNAP_SKIP_VELOCITY = 2;
+
+/** ms window after an inner scroll during which drag is suppressed. (vaul) */
+export const SCROLL_LOCK_TIMEOUT = 100;
+
+/** ms after open during which drag is suppressed so the enter animation can settle. (vaul) */
+export const OPEN_GRACE_PERIOD = 500;
+
+/** Lower bound for the velocity sampling interval, so a stray fast sample can't blow up. (base-ui) */
+export const MIN_SAMPLE_MS = 16;
+
+/** ms; a release velocity sampled longer ago than this is treated as stale (0). (base-ui) */
+export const RELEASE_VEL_MAX_AGE_MS = 80;
diff --git a/packages/headless/src/primitives/drawer/css-vars.ts b/packages/headless/src/primitives/drawer/css-vars.ts
new file mode 100644
index 00000000000..6e63a6c8d00
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/css-vars.ts
@@ -0,0 +1,67 @@
+/**
+ * The styling API for the drawer. The headless layer emits these raw inputs
+ * (CSS custom properties + data attributes) and writes zero CSS. The styled
+ * (mosaic) layer composes the actual `transform`/`opacity`/`calc()` chains from
+ * them — names mirror base-ui's `DrawerPopupCssVars`, namespaced with `--cl-`.
+ */
+export const DrawerCssVars = {
+ /** px live drag delta on the Y axis (0 at rest). */
+ swipeY: '--cl-drawer-swipe-movement-y',
+ /** 0..1 dismiss progress, drives the backdrop fade. */
+ swipeProgress: '--cl-drawer-swipe-progress',
+ /** px translateY of the active snap point at rest. */
+ snapOffset: '--cl-drawer-snap-point-offset',
+ /** 0.1..1 scalar from release velocity; the styled layer scales exit duration by it. */
+ swipeStrength: '--cl-drawer-swipe-strength',
+ /** px measured popup height (ResizeObserver). */
+ height: '--cl-drawer-height',
+ /** count of open nested drawers. */
+ nestedCount: '--cl-drawer-nested-drawers',
+ /** 0..1 dismiss progress of the frontmost nested child (drives the parent's live scale-in). */
+ nestedDragProgress: '--cl-drawer-nested-drag-progress',
+} as const;
+
+export const DrawerAttrs = {
+ /** On Popup/Backdrop during drag — the styled layer zeroes transition-duration so it follows the finger. */
+ swiping: 'data-cl-swiping',
+ /** Active snap point index. */
+ snap: 'data-cl-snap',
+ /** Present when the active snap point is the full-height one. */
+ expanded: 'data-cl-expanded',
+ /** Present on a parent while one of its nested drawers is open. */
+ nestedOpen: 'data-cl-nested-drawer-open',
+ /** Present on a parent while one of its nested drawers is being dragged. */
+ nestedSwiping: 'data-cl-nested-drawer-swiping',
+ /** Present on a drawer that is itself nested inside another. */
+ nested: 'data-cl-nested',
+ /** Marks the grip; also the hit-test target when `handleOnly` is set. */
+ handle: 'data-cl-drawer-handle',
+ /** Consumer opt-out, read by `shouldDrag` to exclude a subtree from dragging. */
+ noDrag: 'data-cl-drawer-no-drag',
+} as const;
+
+/**
+ * Registers the high-frequency vars as non-inheriting custom properties so the
+ * browser can type/animate them cheaply. Safe to call repeatedly: it is a no-op
+ * where `CSS.registerProperty` is unavailable (e.g. happy-dom) and swallows the
+ * duplicate-registration error. Unregistered vars are still settable, so tests
+ * and styling work regardless.
+ */
+export function registerDrawerCssVars(): void {
+ if (typeof CSS === 'undefined' || !('registerProperty' in CSS)) {
+ return;
+ }
+ const defs = [
+ [DrawerCssVars.swipeY, '', '0px'],
+ [DrawerCssVars.snapOffset, '', '0px'],
+ [DrawerCssVars.swipeProgress, '', '0'],
+ [DrawerCssVars.nestedDragProgress, '', '0'],
+ ] as const;
+ for (const [name, syntax, initialValue] of defs) {
+ try {
+ CSS.registerProperty({ name, syntax, inherits: false, initialValue });
+ } catch {
+ /* already registered */
+ }
+ }
+}
diff --git a/packages/headless/src/primitives/drawer/drawer-backdrop.tsx b/packages/headless/src/primitives/drawer/drawer-backdrop.tsx
new file mode 100644
index 00000000000..33a40fdb63c
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-backdrop.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { useMergeRefs } from '@floating-ui/react';
+import React from 'react';
+
+import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
+import { DrawerAttrs } from './css-vars';
+import { useDrawerContext } from './drawer-context';
+
+/** Props for {@link DrawerBackdrop}. */
+export type DrawerBackdropProps = ComponentProps<'div'>;
+
+/** Semi-transparent overlay behind the drawer. Styling reads `--cl-drawer-swipe-progress` to fade during a drag; this part only emits state attributes. */
+export const DrawerBackdrop = React.forwardRef(
+ function DrawerBackdrop(props, ref) {
+ const { render, ...otherProps } = props;
+ const { open, mounted, transitionProps, backdropRef, drag } = useDrawerContext();
+
+ const combinedRef = useMergeRefs([backdropRef, ref]);
+
+ if (!mounted) {
+ return null;
+ }
+
+ const state = { open, swiping: drag.isDragging };
+
+ const defaultProps = {
+ ref: combinedRef,
+ ...transitionProps,
+ } satisfies DefaultProps<'div'>;
+
+ return renderElement({
+ defaultTagName: 'div',
+ render,
+ state,
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
+ swiping: (v: boolean): Record | null => (v ? { [DrawerAttrs.swiping]: '' } : null),
+ },
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ });
+ },
+);
diff --git a/packages/headless/src/primitives/drawer/drawer-close.tsx b/packages/headless/src/primitives/drawer/drawer-close.tsx
new file mode 100644
index 00000000000..cb9b1a43845
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-close.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import React from 'react';
+
+import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
+import { useDrawerContext } from './drawer-context';
+
+/** Props for {@link DrawerClose}. */
+export type DrawerCloseProps = ComponentProps<'button'>;
+
+/** Button that closes the drawer when clicked. Calls `setOpen(false)` from drawer context. */
+export const DrawerClose = React.forwardRef(function DrawerClose(props, ref) {
+ const { render, ...otherProps } = props;
+ const { setOpen } = useDrawerContext();
+
+ const defaultProps = {
+ type: 'button' as const,
+ ref,
+ onClick() {
+ setOpen(false);
+ },
+ } satisfies DefaultProps<'button'>;
+
+ return renderElement({
+ defaultTagName: 'button',
+ render,
+ props: mergeProps<'button'>(defaultProps, otherProps),
+ });
+});
diff --git a/packages/headless/src/primitives/drawer/drawer-context.ts b/packages/headless/src/primitives/drawer/drawer-context.ts
new file mode 100644
index 00000000000..b1e541ba058
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-context.ts
@@ -0,0 +1,66 @@
+'use client';
+
+import { createContext, type PointerEventHandler, useContext } from 'react';
+
+import type { DialogContextValue } from '../dialog/dialog-context';
+
+/** The pointer handlers + dragging flag produced by `useDrawerDrag`. */
+export interface DrawerDrag {
+ onPointerDown: PointerEventHandler;
+ onPointerMove: PointerEventHandler;
+ onPointerUp: PointerEventHandler;
+ onPointerCancel: PointerEventHandler;
+ isDragging: boolean;
+}
+
+/** Callbacks a nested `Drawer.Root` invokes on its parent so the parent can scale/dim back. */
+export interface NestedDrawerCallbacks {
+ onNestedOpenChange: (open: boolean) => void;
+ /** Live 0..1 dismiss progress of the child, reported each pointermove while it drags. */
+ onNestedDrag: (progress: number) => void;
+ /**
+ * The child's drag gesture ended. `childOpen` is whether the child stays open, so the parent
+ * settles its scale toward the right rest (scaled-back if open, fully restored if dismissing)
+ * in a single direction — no flicker.
+ */
+ onNestedRelease: (childOpen: boolean) => void;
+}
+
+export interface DrawerContextValue extends DialogContextValue {
+ backdropRef: React.RefObject;
+ drag: DrawerDrag;
+ /** When true (default), a downward release past threshold closes the drawer. */
+ dismissible: boolean;
+ /** When true, only a press starting on `Drawer.Handle` initiates a drag. */
+ handleOnly: boolean;
+ /** When true, focus moves into the popup on open. Defaults to `false` (unlike Dialog). */
+ autoFocus: boolean;
+ /** Ascending fractions (0..1) of the viewport the drawer rests at, if snap points are enabled. */
+ snapPoints?: number[];
+ /** Index into `snapPoints` of the resting snap point. `-1` when no snap points. */
+ activeSnapPointIndex: number;
+ setActiveSnapPointIndex: (index: number) => void;
+ /** Resting `translateY` (px) of the active snap point, or `null` when no snap points. */
+ snapRestOffset: number | null;
+ /** Callbacks a nested child `Drawer.Root` invokes on this (parent) drawer. */
+ onNested: NestedDrawerCallbacks;
+ /** True when this drawer is itself nested inside another drawer. */
+ isNested: boolean;
+ /** How many direct nested child drawers are currently open. */
+ nestedOpenCount: number;
+}
+
+export const DrawerContext = createContext(null);
+
+export function useDrawerContext(): DrawerContextValue {
+ const ctx = useContext(DrawerContext);
+ if (!ctx) {
+ throw new Error('Drawer compound components must be used within ');
+ }
+ return ctx;
+}
+
+/** Reads the parent drawer context without throwing — `null` when not nested. */
+export function useParentDrawerContext(): DrawerContextValue | null {
+ return useContext(DrawerContext);
+}
diff --git a/packages/headless/src/primitives/drawer/drawer-description.tsx b/packages/headless/src/primitives/drawer/drawer-description.tsx
new file mode 100644
index 00000000000..347e4cd9896
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-description.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import React from 'react';
+
+import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
+import { useDrawerContext } from './drawer-context';
+
+/** Props for {@link DrawerDescription}. */
+export type DrawerDescriptionProps = Omit, 'id'>;
+
+/** Accessible drawer description. Wires its `id` to `aria-describedby` on `Drawer.Popup`. */
+export const DrawerDescription = React.forwardRef(
+ function DrawerDescription(props, ref) {
+ const { render, ...otherProps } = props;
+ const { descriptionId } = useDrawerContext();
+
+ const defaultProps = {
+ id: descriptionId,
+ ref,
+ } satisfies DefaultProps<'p'>;
+
+ return renderElement({
+ defaultTagName: 'p',
+ render,
+ props: mergeProps<'p'>(defaultProps, otherProps),
+ });
+ },
+);
diff --git a/packages/headless/src/primitives/drawer/drawer-handle-grip.tsx b/packages/headless/src/primitives/drawer/drawer-handle-grip.tsx
new file mode 100644
index 00000000000..ebd380ff0dc
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-handle-grip.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import React from 'react';
+
+import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
+import { DrawerAttrs } from './css-vars';
+
+/** Props for {@link DrawerHandleGrip}. */
+export type DrawerHandleProps = ComponentProps<'div'>;
+
+/**
+ * The visual drag grip. Carries `data-cl-drawer-handle`, which is also the
+ * hit-test target when `Drawer.Root` has `handleOnly`. It is presentational by
+ * default (no ARIA role) so it does not add a nameless control to the
+ * accessibility tree — keyboard users dismiss via `Escape` / `Drawer.Close`.
+ * Apply your own `role`/`aria-*` via props or `render` if you need it announced.
+ */
+export const DrawerHandleGrip = React.forwardRef(
+ function DrawerHandleGrip(props, ref) {
+ const { render, ...otherProps } = props;
+
+ const defaultProps = {
+ ref,
+ [DrawerAttrs.handle]: '',
+ } satisfies DefaultProps<'div'>;
+
+ return renderElement({
+ defaultTagName: 'div',
+ render,
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ });
+ },
+);
diff --git a/packages/headless/src/primitives/drawer/drawer-handle.ts b/packages/headless/src/primitives/drawer/drawer-handle.ts
new file mode 100644
index 00000000000..2576b11ccf7
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-handle.ts
@@ -0,0 +1,94 @@
+'use client';
+
+/**
+ * @internal The connection a mounted `Drawer.Root` gives the handle: a setter
+ * that runs the real open/close (firing `onOpenChange` and respecting a
+ * controlled `open` prop) and a getter for the root's current open state.
+ */
+export interface DrawerHandleController {
+ setOpen: (open: boolean) => void;
+ getOpen: () => boolean;
+}
+
+/**
+ * An imperative handle for opening a drawer from a trigger that lives outside
+ * `Drawer.Root` (a "detached" trigger). Create one with {@link createDrawerHandle},
+ * pass it to both `Drawer.Root` and `Drawer.Trigger`, and they share a single
+ * open state.
+ *
+ * `Drawer.Root` remains the single source of truth (it owns `open` / `defaultOpen`
+ * / `onOpenChange`); the handle is a bridge. Imperative calls route back through
+ * the root's `setOpen`, so `onOpenChange` always fires, and `isOpen` reflects the
+ * root. The store is `useSyncExternalStore`-compatible.
+ */
+export interface DrawerHandle {
+ open(): void;
+ close(): void;
+ toggle(): void;
+ readonly isOpen: boolean;
+ subscribe(callback: () => void): () => void;
+ /**
+ * @internal Connect the owning `Drawer.Root`. Returns a disconnect callback.
+ */
+ connect(controller: DrawerHandleController): () => void;
+ /**
+ * @internal The root calls this after each open change so external subscribers
+ * (e.g. a detached `Drawer.Trigger`) re-read `isOpen`.
+ */
+ emit(): void;
+}
+
+export function createDrawerHandle(): DrawerHandle {
+ let controller: DrawerHandleController | null = null;
+ // Open state used only while no `Drawer.Root` is connected (e.g. an imperative
+ // `open()` before mount). `touched` records that it was set deliberately so a
+ // connecting root can adopt it without clobbering its own `defaultOpen`.
+ let buffered = false;
+ let touched = false;
+ const listeners = new Set<() => void>();
+
+ const emit = (): void => {
+ for (const listener of listeners) {
+ listener();
+ }
+ };
+
+ const command = (next: boolean): void => {
+ if (controller) {
+ controller.setOpen(next);
+ return;
+ }
+ buffered = next;
+ touched = true;
+ emit();
+ };
+
+ return {
+ open: () => command(true),
+ close: () => command(false),
+ toggle: () => command(!(controller ? controller.getOpen() : buffered)),
+ get isOpen() {
+ return controller ? controller.getOpen() : buffered;
+ },
+ subscribe(callback) {
+ listeners.add(callback);
+ return () => {
+ listeners.delete(callback);
+ };
+ },
+ connect(next) {
+ controller = next;
+ // Adopt an open/close requested imperatively before the root mounted.
+ if (touched && buffered !== next.getOpen()) {
+ next.setOpen(buffered);
+ }
+ touched = false;
+ emit();
+ return () => {
+ controller = null;
+ emit();
+ };
+ },
+ emit,
+ };
+}
diff --git a/packages/headless/src/primitives/drawer/drawer-popup.tsx b/packages/headless/src/primitives/drawer/drawer-popup.tsx
new file mode 100644
index 00000000000..e90a8ae6c07
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-popup.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react';
+import React, { useEffect } from 'react';
+
+import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
+import { DrawerAttrs, DrawerCssVars } from './css-vars';
+import { useDrawerContext } from './drawer-context';
+
+/** Props for {@link DrawerPopup}. */
+export type DrawerPopupProps = ComponentProps<'div'>;
+
+/**
+ * The drawer sheet (`role="dialog"`). Hosts the drag gesture, focus trapping
+ * (`FloatingFocusManager`), and ARIA wiring. Unless `Drawer.Root` sets
+ * `autoFocus`, focus moves to the sheet container rather than its first field, so
+ * opening on touch does not summon the keyboard.
+ */
+export const DrawerPopup = React.forwardRef(function DrawerPopup(props, ref) {
+ const { render, ...otherProps } = props;
+ const {
+ popupRef,
+ refs,
+ getFloatingProps,
+ floatingContext,
+ modal,
+ labelId,
+ descriptionId,
+ mounted,
+ transitionProps,
+ drag,
+ autoFocus,
+ snapPoints,
+ activeSnapPointIndex,
+ snapRestOffset,
+ isNested,
+ nestedOpenCount,
+ } = useDrawerContext();
+
+ // floating-ui types `setFloating` as a method signature, but at runtime it's
+ // a stable callback that doesn't use `this`, so the unbound-method check is a
+ // false positive here.
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const combinedRef = useMergeRefs([popupRef, refs.setFloating, ref]);
+
+ // The nested-child count is a raw CSS input for the styled stack math. Written
+ // imperatively (a `--*` custom property) rather than via React inline style.
+ useEffect(() => {
+ popupRef.current?.style.setProperty(DrawerCssVars.nestedCount, String(nestedOpenCount));
+ }, [popupRef, nestedOpenCount]);
+
+ // Apply the resting snap-point offset here (the popup owns the ref and is
+ // mounted), covering initial open and controlled `activeSnapPoint` changes.
+ useEffect(() => {
+ if (snapRestOffset === null) {
+ return;
+ }
+ popupRef.current?.style.setProperty(DrawerCssVars.snapOffset, `${snapRestOffset}px`);
+ }, [popupRef, snapRestOffset]);
+
+ if (!mounted) {
+ return null;
+ }
+
+ const ownProps = {
+ ref: combinedRef,
+ tabIndex: -1,
+ 'aria-labelledby': labelId,
+ 'aria-describedby': descriptionId,
+ style: { touchAction: 'none' as const },
+ // Always attached; `handleOnly` is enforced inside the engine's pointer-down gate.
+ onPointerDown: drag.onPointerDown,
+ onPointerMove: drag.onPointerMove,
+ onPointerUp: drag.onPointerUp,
+ onPointerCancel: drag.onPointerCancel,
+ } satisfies DefaultProps<'div'>;
+
+ const withFloating = mergeProps<'div'>(ownProps, getFloatingProps());
+ const defaultProps = mergeProps<'div'>(withFloating, transitionProps);
+
+ // An empty array carries no snap points, so it must not surface snap state.
+ const hasSnapPoints = snapPoints !== undefined && snapPoints.length > 0;
+ const state = {
+ swiping: drag.isDragging,
+ snap: hasSnapPoints ? activeSnapPointIndex : null,
+ expanded: hasSnapPoints ? activeSnapPointIndex === snapPoints.length - 1 : false,
+ nested: isNested,
+ nestedOpen: nestedOpenCount > 0,
+ };
+
+ return (
+
+ {renderElement({
+ defaultTagName: 'div',
+ render,
+ state,
+ stateAttributesMapping: {
+ swiping: (v): Record | null => (v ? { [DrawerAttrs.swiping]: '' } : null),
+ snap: (v): Record | null => (v === null ? null : { [DrawerAttrs.snap]: String(v) }),
+ expanded: (v): Record | null => (v ? { [DrawerAttrs.expanded]: '' } : null),
+ nested: (v): Record | null => (v ? { [DrawerAttrs.nested]: '' } : null),
+ nestedOpen: (v): Record | null => (v ? { [DrawerAttrs.nestedOpen]: '' } : null),
+ },
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ })}
+
+ );
+});
diff --git a/packages/headless/src/primitives/drawer/drawer-portal.tsx b/packages/headless/src/primitives/drawer/drawer-portal.tsx
new file mode 100644
index 00000000000..c4cf9a1605f
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-portal.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { FloatingPortal } from '@floating-ui/react';
+import type { ReactNode } from 'react';
+
+import { useDrawerContext } from './drawer-context';
+
+export interface DrawerPortalProps {
+ children: ReactNode;
+ root?: HTMLElement | null | React.RefObject;
+}
+
+export function DrawerPortal(props: DrawerPortalProps) {
+ const { mounted } = useDrawerContext();
+
+ if (!mounted) {
+ return null;
+ }
+
+ return {props.children} ;
+}
diff --git a/packages/headless/src/primitives/drawer/drawer-root.tsx b/packages/headless/src/primitives/drawer/drawer-root.tsx
new file mode 100644
index 00000000000..b6b968b296f
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-root.tsx
@@ -0,0 +1,288 @@
+'use client';
+
+import {
+ FloatingNode,
+ FloatingTree,
+ useClick,
+ useDismiss,
+ useFloating,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useInteractions,
+ useRole,
+} from '@floating-ui/react';
+import { type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+
+import { useControllableState } from '../../hooks/use-controllable-state';
+import { useTransition } from '../../hooks/use-transition';
+import { DrawerAttrs, DrawerCssVars, registerDrawerCssVars } from './css-vars';
+import {
+ DrawerContext,
+ type DrawerContextValue,
+ type NestedDrawerCallbacks,
+ useParentDrawerContext,
+} from './drawer-context';
+import type { DrawerHandle } from './drawer-handle';
+import { useDrawerDrag } from './use-drawer-drag';
+import { useRepositionInputs } from './use-reposition-inputs';
+import { useSnapPoints } from './use-snap-points';
+
+export interface DrawerProps {
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ /** Traps focus and blocks interaction with the rest of the page. Default: true */
+ modal?: boolean;
+ /** Imperative handle for a trigger rendered outside `Drawer.Root`. */
+ handle?: DrawerHandle;
+ /** Ascending fractions (0..1) of the viewport the drawer rests at. */
+ snapPoints?: number[];
+ /** Controlled active snap point index. */
+ activeSnapPoint?: number;
+ /** Uncontrolled initial active snap point index. Defaults to the last (most open). */
+ defaultActiveSnapPoint?: number;
+ onActiveSnapPointChange?: (index: number) => void;
+ /** When true (default), a downward release past threshold (or outside press) closes the drawer. */
+ dismissible?: boolean;
+ /** When true, only a press starting on `Drawer.Handle` initiates a drag. Default: false */
+ handleOnly?: boolean;
+ /** Keep a focused field visible above the virtual keyboard. Default: true */
+ repositionInputs?: boolean;
+ /** Move focus into the popup on open. Default: false (so opening on touch does not pop the keyboard). */
+ autoFocus?: boolean;
+ /**
+ * Internal test seam: an injectable clock for deterministic drag velocity.
+ * @internal
+ */
+ _now?: () => number;
+ children: ReactNode;
+}
+
+const defaultNow = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
+
+function DrawerInner(props: DrawerProps) {
+ const {
+ modal = true,
+ handle,
+ snapPoints,
+ dismissible = true,
+ handleOnly = false,
+ repositionInputs = true,
+ autoFocus = false,
+ children,
+ } = props;
+
+ const parent = useParentDrawerContext();
+ const isNested = parent !== null;
+ const nodeId = useFloatingNodeId();
+
+ // Stable clock wrapper so the engine's handlers/effects don't churn when a test
+ // passes a fresh `_now` each render.
+ const nowRef = useRef(defaultNow);
+ nowRef.current = props._now ?? defaultNow;
+ const now = useCallback(() => nowRef.current(), []);
+
+ // `Drawer.Root` is the single source of truth for open state (controlled
+ // `open`/`defaultOpen` + `onOpenChange`). A detached `handle` is only a bridge:
+ // its imperative calls route back through `setOpen` (below) so `onOpenChange`
+ // always fires and a controlled `open` prop is respected.
+ const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);
+
+ // Read via refs so the handle connection stays stable across renders even when
+ // the consumer passes a fresh `onOpenChange` (which re-creates `setOpen`).
+ const openRef = useRef(open);
+ openRef.current = open;
+ const setOpenRef = useRef(setOpen);
+ setOpenRef.current = setOpen;
+ useEffect(() => {
+ if (!handle) {
+ return;
+ }
+ return handle.connect({
+ setOpen: next => setOpenRef.current(next),
+ getOpen: () => openRef.current,
+ });
+ }, [handle]);
+ // Push our open state to the handle's subscribers (e.g. a detached trigger).
+ useEffect(() => {
+ handle?.emit();
+ }, [handle, open]);
+
+ const labelId = useId();
+ const descriptionId = useId();
+ const popupRef = useRef(null);
+ const backdropRef = useRef(null);
+
+ const { refs, context: floatingContext } = useFloating({
+ nodeId,
+ open,
+ onOpenChange: setOpen,
+ });
+
+ const { mounted, transitionProps } = useTransition({ open, ref: popupRef });
+
+ const click = useClick(floatingContext);
+ const dismiss = useDismiss(floatingContext, { outsidePressEvent: 'mousedown', enabled: dismissible });
+ const role = useRole(floatingContext);
+ const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
+
+ // CSS-var writers. `setSwipe` is the single writer of the live swipe-y, keeping
+ // the var and the `curSwipe` ref in lockstep so drag decisions can read the ref.
+ const curSwipe = useRef(0);
+ const setVar = useCallback((name: string, value: string) => {
+ popupRef.current?.style.setProperty(name, value);
+ }, []);
+ const setSwipe = useCallback(
+ (px: number) => {
+ curSwipe.current = px;
+ setVar(DrawerCssVars.swipeY, `${px}px`);
+ },
+ [setVar],
+ );
+
+ const close = useCallback(() => setOpen(false), [setOpen]);
+
+ const snap = useSnapPoints({
+ snapPoints,
+ activeSnapPoint: props.activeSnapPoint,
+ defaultActiveSnapPoint: props.defaultActiveSnapPoint,
+ onActiveSnapPointChange: props.onActiveSnapPointChange,
+ setVar,
+ setSwipe,
+ open,
+ });
+
+ const drag = useDrawerDrag({
+ popupRef,
+ open,
+ dismissible,
+ handleOnly,
+ snapPoints,
+ snap,
+ close,
+ now,
+ setVar,
+ setSwipe,
+ curSwipe,
+ onNestedDrag: parent?.onNested.onNestedDrag,
+ onNestedRelease: parent?.onNested.onNestedRelease,
+ });
+
+ useEffect(() => {
+ registerDrawerCssVars();
+ }, []);
+
+ useRepositionInputs({ enabled: repositionInputs && open, popupRef });
+
+ // Nested wiring: count open children (for the parent's stack vars) and notify
+ // our own parent when this drawer opens/closes.
+ const [nestedOpenCount, setNestedOpenCount] = useState(0);
+ const onNested = useMemo(
+ () => ({
+ onNestedOpenChange: (childOpen: boolean) => {
+ setNestedOpenCount(count => Math.max(0, childOpen ? count + 1 : count - 1));
+ // Start each nesting at the scaled-back rest (progress 0). Without this a
+ // prior dismiss (which parks progress at 1) would leave the next child's
+ // parent un-scaled.
+ if (childOpen) {
+ setVar(DrawerCssVars.nestedDragProgress, '0');
+ }
+ },
+ // High-frequency: write straight to the popup (like the drag engine) instead
+ // of routing through React state, so a nested child's drag stays 60fps.
+ onNestedDrag: (progress: number) => {
+ setVar(DrawerCssVars.nestedDragProgress, String(progress));
+ popupRef.current?.setAttribute(DrawerAttrs.nestedSwiping, '');
+ },
+ // Settle toward the rest the drawer is heading to: scaled-back (0) if the
+ // child stays open, fully restored (1) if it is dismissing. That matches the
+ // open-count dropping, so the styled scale animates one way — no flicker.
+ onNestedRelease: (childOpen: boolean) => {
+ setVar(DrawerCssVars.nestedDragProgress, childOpen ? '0' : '1');
+ popupRef.current?.removeAttribute(DrawerAttrs.nestedSwiping);
+ },
+ }),
+ [setVar],
+ );
+
+ useEffect(() => {
+ if (!parent || !open) {
+ return;
+ }
+ parent.onNested.onNestedOpenChange(true);
+ return () => parent.onNested.onNestedOpenChange(false);
+ }, [parent, open]);
+
+ const contextValue = useMemo(
+ () => ({
+ open,
+ setOpen,
+ floatingContext,
+ refs,
+ getReferenceProps,
+ getFloatingProps,
+ popupRef,
+ backdropRef,
+ modal,
+ labelId,
+ descriptionId,
+ mounted,
+ transitionProps,
+ drag,
+ dismissible,
+ handleOnly,
+ autoFocus,
+ snapPoints,
+ activeSnapPointIndex: snap ? snap.activeIndex : -1,
+ setActiveSnapPointIndex: snap ? snap.setActiveIndex : noopSetIndex,
+ snapRestOffset: snap ? snap.restOffset : null,
+ onNested,
+ isNested,
+ nestedOpenCount,
+ }),
+ [
+ open,
+ setOpen,
+ floatingContext,
+ refs,
+ getReferenceProps,
+ getFloatingProps,
+ modal,
+ labelId,
+ descriptionId,
+ mounted,
+ transitionProps,
+ drag,
+ dismissible,
+ handleOnly,
+ autoFocus,
+ snapPoints,
+ snap,
+ onNested,
+ isNested,
+ nestedOpenCount,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+const noopSetIndex = (): void => {};
+
+export function DrawerRoot(props: DrawerProps) {
+ const parentId = useFloatingParentNodeId();
+
+ if (parentId === null) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/packages/headless/src/primitives/drawer/drawer-title.tsx b/packages/headless/src/primitives/drawer/drawer-title.tsx
new file mode 100644
index 00000000000..bceb9385030
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-title.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import React from 'react';
+
+import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
+import { useDrawerContext } from './drawer-context';
+
+/** Props for {@link DrawerTitle}. */
+export type DrawerTitleProps = Omit, 'id'>;
+
+/** Accessible drawer heading. Wires its `id` to `aria-labelledby` on `Drawer.Popup`. */
+export const DrawerTitle = React.forwardRef(function DrawerTitle(props, ref) {
+ const { render, ...otherProps } = props;
+ const { labelId } = useDrawerContext();
+
+ const defaultProps = {
+ id: labelId,
+ ref,
+ } satisfies DefaultProps<'h2'>;
+
+ return renderElement({
+ defaultTagName: 'h2',
+ render,
+ props: mergeProps<'h2'>(defaultProps, otherProps),
+ });
+});
diff --git a/packages/headless/src/primitives/drawer/drawer-trigger.tsx b/packages/headless/src/primitives/drawer/drawer-trigger.tsx
new file mode 100644
index 00000000000..9c52f96e1d0
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-trigger.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { useMergeRefs } from '@floating-ui/react';
+import React, { useCallback, useContext, useSyncExternalStore } from 'react';
+
+import { type ComponentProps, mergeProps, renderElement } from '../../utils';
+import { DrawerContext } from './drawer-context';
+import type { DrawerHandle } from './drawer-handle';
+
+/** Props for {@link DrawerTrigger}. */
+export interface DrawerTriggerProps extends ComponentProps<'button'> {
+ /**
+ * When provided, the trigger drives this detached handle instead of the
+ * surrounding `Drawer.Root` context — letting it live anywhere in the tree.
+ */
+ handle?: DrawerHandle;
+}
+
+const noopSubscribe = (): (() => void) => () => {};
+
+/**
+ * Button that opens the drawer. Inside `Drawer.Root` it wires to Floating UI's
+ * reference element (like Dialog); given a `handle`, it instead toggles that
+ * detached handle and may be rendered outside the root.
+ */
+export const DrawerTrigger = React.forwardRef(
+ function DrawerTrigger(props, ref) {
+ const { render, handle, ...otherProps } = props;
+ const ctx = useContext(DrawerContext);
+
+ // Detached open state (only consulted when `handle` is provided).
+ const detachedOpen = useSyncExternalStore(
+ useCallback((cb: () => void) => handle?.subscribe(cb) ?? noopSubscribe(), [handle]),
+ () => handle?.isOpen ?? false,
+ () => handle?.isOpen ?? false,
+ );
+
+ // floating-ui types `setReference` as a method signature, but at runtime it's
+ // a stable callback that doesn't use `this`, so the unbound-method check is a
+ // false positive here.
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const combinedRef = useMergeRefs([ctx?.refs.setReference, ref]);
+
+ if (!handle && !ctx) {
+ throw new Error('Drawer.Trigger must be used within or be given a `handle`');
+ }
+
+ const open = handle ? detachedOpen : (ctx?.open ?? false);
+
+ const defaultProps = handle
+ ? {
+ type: 'button' as const,
+ ref,
+ onClick: () => handle.toggle(),
+ }
+ : { type: 'button' as const, ref: combinedRef, ...ctx?.getReferenceProps() };
+
+ return renderElement({
+ defaultTagName: 'button',
+ render,
+ state: { open },
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
+ },
+ props: mergeProps<'button'>(defaultProps, otherProps),
+ });
+ },
+);
diff --git a/packages/headless/src/primitives/drawer/drawer-viewport.tsx b/packages/headless/src/primitives/drawer/drawer-viewport.tsx
new file mode 100644
index 00000000000..8d74cf801c0
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer-viewport.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { FloatingOverlay } from '@floating-ui/react';
+import React from 'react';
+
+import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
+import { useDrawerContext } from './drawer-context';
+
+/** Props for {@link DrawerViewport}. */
+export interface DrawerViewportProps extends ComponentProps<'div'> {
+ /** When true, locks body scroll while the drawer is open. Default: true */
+ lockScroll?: boolean;
+}
+
+/**
+ * Fixed full-viewport container for the drawer. Wraps `FloatingOverlay` for
+ * scroll-locking; the sheet (`Drawer.Popup`) is positioned against the bottom
+ * edge by the styled layer. Scroll-lock lives here, not on `Drawer.Backdrop`,
+ * so the backdrop surface can be styled independently.
+ */
+export const DrawerViewport = React.forwardRef(
+ function DrawerViewport(props, ref) {
+ const { render, lockScroll = true, ...otherProps } = props;
+ const { open, mounted, transitionProps, modal } = useDrawerContext();
+
+ if (!mounted) {
+ return null;
+ }
+
+ const state = { open };
+
+ const defaultProps = {
+ ref,
+ ...transitionProps,
+ style: modal ? undefined : { pointerEvents: 'auto' as const },
+ } satisfies DefaultProps<'div'>;
+
+ return (
+
+ {renderElement({
+ defaultTagName: 'div',
+ render,
+ state,
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null =>
+ v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
+ },
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ })}
+
+ );
+ },
+);
diff --git a/packages/headless/src/primitives/drawer/drawer.ssr.test.tsx b/packages/headless/src/primitives/drawer/drawer.ssr.test.tsx
new file mode 100644
index 00000000000..17ca8ce0d8e
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer.ssr.test.tsx
@@ -0,0 +1,47 @@
+// @vitest-environment node
+import { renderToString } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+
+import * as Drawer from './parts';
+
+// These render on the server, where `window` does not exist. A part that reads
+// `window` during render (rather than in an effect) crashes the whole server
+// render, so every drawer configuration must be SSR-safe.
+describe('Drawer SSR (no window)', () => {
+ it('has no window in this environment', () => {
+ expect(typeof window).toBe('undefined');
+ });
+
+ it('renders a plain drawer on the server', () => {
+ expect(() =>
+ renderToString(
+
+
+
+ hi
+
+
+ ,
+ ),
+ ).not.toThrow();
+ });
+
+ // Regression: `useSnapPoints` computed the resting offset from `window.innerHeight`
+ // during render, which threw `ReferenceError: window is not defined` on the server.
+ it('renders a drawer with snapPoints on the server', () => {
+ expect(() =>
+ renderToString(
+
+
+
+ hi
+
+
+ ,
+ ),
+ ).not.toThrow();
+ });
+});
diff --git a/packages/headless/src/primitives/drawer/drawer.test.tsx b/packages/headless/src/primitives/drawer/drawer.test.tsx
new file mode 100644
index 00000000000..9f6924a201a
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/drawer.test.tsx
@@ -0,0 +1,1564 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { axe } from '../../test-utils/axe';
+import { Dialog } from '../dialog/index';
+import { Select } from '../select/index';
+import { OPEN_GRACE_PERIOD } from './constants';
+import { DrawerCssVars } from './css-vars';
+import { getSnapPointSwipeMovement, safeCapture } from './helpers';
+import { createDrawerHandle, Drawer } from './index';
+
+// happy-dom does not implement pointer capture; the engine's safeCapture already
+// try/catches, but stub so nothing throws and the calls are observable.
+beforeAll(() => {
+ HTMLElement.prototype.setPointerCapture ??= vi.fn();
+ HTMLElement.prototype.releasePointerCapture ??= vi.fn();
+});
+
+afterEach(() => {
+ cleanup();
+ window.getSelection()?.removeAllRanges();
+});
+
+// Injectable clock (the `_now` test seam) so drag velocity is deterministic.
+const clock = { t: 0 };
+beforeEach(() => {
+ clock.t = 0;
+});
+
+function DrawerFixture(props: Partial> = {}) {
+ return (
+ clock.t}
+ >
+ Open drawer
+
+
+
+
+
+ Drawer Title
+ Some drawer description
+ scrollable content
+
+ No drag
+
+
+ a
+
+
+ Drawer body content
+ Close
+
+
+
+
+ );
+}
+
+// A drawer whose popup hosts form controls that the drag gate must not hijack.
+function ControlsFixture(props: Partial> = {}) {
+ return (
+ clock.t}
+ >
+
+
+
+
+ Controls
+
+
+
+
+
+ Editable text
+
+ Plain selectable text
+
+
+
+
+ );
+}
+
+// A drawer whose popup is wrapped in a scrollable ancestor (inside the viewport).
+function AncestorScrollFixture(props: Partial> = {}) {
+ return (
+ clock.t}
+ >
+
+
+
+
+ Ancestor
+ content
+
+
+
+
+
+ );
+}
+
+// A drawer hosting a (non-drawer) Dialog, which must not be counted as nested.
+function DrawerWithDialog() {
+ return (
+ clock.t}
+ >
+
+
+
+ Drawer with dialog
+
+ Open dialog
+
+
+
+ Inner dialog
+ Close dialog
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Selects the first ~5 characters of an element's text via the window selection. */
+function selectText(el: HTMLElement) {
+ const selection = window.getSelection();
+ const node = el.firstChild;
+ if (!selection || !node) {
+ return;
+ }
+ const range = document.createRange();
+ range.setStart(node, 0);
+ range.setEnd(node, Math.min(5, (node.textContent ?? '').length));
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
+
+// ---------------------------------------------------------------------------
+// Drag harness
+// ---------------------------------------------------------------------------
+
+function stubHeight(el: HTMLElement, height: number) {
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ height } as DOMRect);
+}
+
+// A `getBoundingClientRect` that reflects an applied `style.height` cap the way a
+// real browser does, so tests can exercise the read-back-what-you-just-set path.
+function stubMeasuredHeight(el: HTMLElement, naturalHeight: number) {
+ vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
+ () => ({ height: el.style.height ? parseFloat(el.style.height) : naturalHeight }) as DOMRect,
+ );
+}
+
+function makeScrollable(
+ el: HTMLElement,
+ { scrollHeight, clientHeight, scrollTop }: { scrollHeight: number; clientHeight: number; scrollTop: number },
+) {
+ Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true });
+ Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true });
+ el.scrollTop = scrollTop;
+}
+
+interface DragOptions {
+ steps?: number;
+ /** Advance the clock past the open-grace window before pressing (default true). */
+ settle?: boolean;
+}
+
+function drag(el: HTMLElement, from: number, to: number, ms: number, { steps = 4, settle = true }: DragOptions = {}) {
+ if (settle) {
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ }
+ fireEvent.pointerDown(el, { pointerId: 1, clientY: from, button: 0, isPrimary: true, pointerType: 'touch' });
+ for (let i = 1; i <= steps; i++) {
+ clock.t += ms / steps;
+ fireEvent.pointerMove(el, { pointerId: 1, clientY: from + ((to - from) * i) / steps });
+ }
+ fireEvent.pointerUp(el, { pointerId: 1, clientY: to });
+}
+
+const swipeY = (el: HTMLElement) => el.style.getPropertyValue(DrawerCssVars.swipeY);
+const swipeProgress = (el: HTMLElement) => el.style.getPropertyValue(DrawerCssVars.swipeProgress);
+
+describe('Drawer', () => {
+ describe('open/close + parts', () => {
+ it('opens on trigger click', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const trigger = screen.getByRole('button', { name: 'Open drawer' });
+ await user.click(trigger);
+
+ expect(trigger).toHaveAttribute('data-cl-open', '');
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('closes on Escape', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.keyboard('{Escape}');
+
+ expect(screen.getByTestId('trigger')).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('closes via Close button', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(screen.getByTestId('trigger')).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('calls onOpenChange when toggled', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it('respects controlled open prop', () => {
+ render( );
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('does not open when controlled open is false', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('does not render content when closed', () => {
+ render( );
+ expect(screen.queryByText('Drawer body content')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('backdrop')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('viewport')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('dismiss behavior', () => {
+ it('outside press closes a modal drawer', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // FloatingOverlay covers the page; press the viewport background.
+ await user.click(document.body);
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('does not close on outside press when dismissible is false', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(document.body);
+ await user.keyboard('{Escape}');
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+ });
+
+ describe('detached handle', () => {
+ function DetachedFixture(
+ handle: ReturnType,
+ props: Partial> = {},
+ ) {
+ return (
+ <>
+
+ Open external
+
+ clock.t}
+ {...props}
+ >
+
+
+
+ External Drawer
+ Close
+
+
+
+
+ >
+ );
+ }
+
+ it('a trigger outside Root opens the drawer via a shared handle', async () => {
+ const user = userEvent.setup();
+ const handle = createDrawerHandle();
+ render(DetachedFixture(handle));
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId('ext-trigger'));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(handle.isOpen).toBe(true);
+ expect(screen.getByTestId('ext-trigger')).toHaveAttribute('data-cl-open', '');
+ });
+
+ it('handle.close() closes the drawer and updates the trigger', async () => {
+ const user = userEvent.setup();
+ const handle = createDrawerHandle();
+ render(DetachedFixture(handle));
+
+ await user.click(screen.getByTestId('ext-trigger'));
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(handle.isOpen).toBe(false);
+ expect(screen.getByTestId('ext-trigger')).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('fires onOpenChange for handle-driven transitions', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ const handle = createDrawerHandle();
+ render(DetachedFixture(handle, { onOpenChange }));
+
+ await user.click(screen.getByTestId('ext-trigger'));
+ expect(onOpenChange).toHaveBeenLastCalledWith(true);
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+ expect(onOpenChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it('honors defaultOpen when a handle is provided', () => {
+ const handle = createDrawerHandle();
+ render(DetachedFixture(handle, { defaultOpen: true }));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(handle.isOpen).toBe(true);
+ });
+
+ it('honors a controlled open prop when a handle is provided', () => {
+ const handle = createDrawerHandle();
+ const { rerender } = render(DetachedFixture(handle, { open: false }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ rerender(DetachedFixture(handle, { open: true }));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(handle.isOpen).toBe(true);
+ });
+
+ it('adopts an imperative open requested before the root mounts', () => {
+ const handle = createDrawerHandle();
+ handle.open(); // called before render/connect
+ render(DetachedFixture(handle));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(handle.isOpen).toBe(true);
+ });
+ });
+
+ describe('ARIA + lifecycle', () => {
+ it('popup has role=dialog with aria-labelledby/describedby', () => {
+ render( );
+
+ const popup = screen.getByRole('dialog');
+ const title = screen.getByText('Drawer Title');
+ const desc = screen.getByText('Some drawer description');
+
+ expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
+ expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
+ });
+
+ it('applies data-cl-open on popup, backdrop and viewport when open', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
+ expect(screen.getByTestId('backdrop')).toHaveAttribute('data-cl-open', '');
+ expect(screen.getByTestId('viewport')).toHaveAttribute('data-cl-open', '');
+ });
+ });
+
+ describe('focus management', () => {
+ it('does not move focus to an inner field by default (autoFocus=false)', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+ await new Promise(r => requestAnimationFrame(r));
+
+ const dialog = screen.getByRole('dialog');
+ expect(screen.getByTestId('field')).not.toHaveFocus();
+ expect(dialog.contains(document.activeElement)).toBe(true);
+ });
+
+ it('returns focus to the trigger on close', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const trigger = screen.getByRole('button', { name: 'Open drawer' });
+ await user.click(trigger);
+ await user.keyboard('{Escape}');
+
+ expect(trigger).toHaveFocus();
+ });
+ });
+
+ describe('drag to dismiss (no snap points)', () => {
+ it('drag past the close threshold closes', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ drag(popup, 0, 120, 200); // 120 > 400 * 0.25
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('a small slow drag resets and stays open', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ drag(popup, 0, 40, 300); // 40 < 100, slow
+
+ expect(swipeY(popup)).toBe('0px');
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('a fast downward flick closes regardless of distance', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ drag(popup, 0, 60, 20); // 60px in 20ms => 3px/ms > 0.4
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('a fast upward flick after dragging down stays open', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(popup, { pointerId: 1, clientY: 0, button: 0, pointerType: 'touch' });
+ clock.t += 200;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientY: 80 }); // dragged down, below the 100px threshold
+ clock.t += 10;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientY: 50 }); // fast flick back up (still net-downward)
+ fireEvent.pointerUp(popup, { pointerId: 1, clientY: 50 });
+
+ // The release velocity is upward, so it must not read as a downward flick-to-dismiss.
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('0px');
+ });
+
+ it('does not drag at all when dismissible is false and no snap points', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ drag(popup, 0, 200, 200);
+
+ expect(swipeY(popup)).toBe('');
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it('updates the swipe-progress var and swiping attribute during a drag', () => {
+ render( );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(popup, { pointerId: 1, clientY: 0, button: 0, pointerType: 'touch' });
+ clock.t += 50;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientY: 50 });
+
+ expect(popup).toHaveAttribute('data-cl-swiping', '');
+ expect(swipeProgress(popup)).toBe('0.125'); // 50 / 400
+
+ fireEvent.pointerUp(popup, { pointerId: 1, clientY: 50 });
+ expect(popup).not.toHaveAttribute('data-cl-swiping');
+ });
+
+ it('rubber-bands upward over-drag without ever moving the sheet downward', () => {
+ render( );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(popup, { pointerId: 1, clientY: 100, button: 0, pointerType: 'touch' });
+ clock.t += 30;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientY: 150 }); // commit (down)
+ clock.t += 30;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientY: 97 }); // up 3px past start
+
+ expect(parseFloat(swipeY(popup))).toBeLessThanOrEqual(0);
+
+ fireEvent.pointerUp(popup, { pointerId: 1, clientY: 97 });
+ });
+
+ it('removes the iOS touchend fallback listener on release (no leak per gesture)', () => {
+ // iOS registers a `touchend` fallback on pointerdown (it may not dispatch
+ // pointerup after a scroll-cancelled gesture). A normal release must remove
+ // it so listeners can't pile up on `window` across gestures.
+ const hadPlatform = Object.getOwnPropertyDescriptor(navigator, 'platform');
+ Object.defineProperty(navigator, 'platform', { value: 'iPhone', configurable: true });
+ const add = vi.spyOn(window, 'addEventListener');
+ const remove = vi.spyOn(window, 'removeEventListener');
+ try {
+ render( );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(popup, { pointerId: 1, clientY: 0, button: 0, pointerType: 'touch' });
+ fireEvent.pointerUp(popup, { pointerId: 1, clientY: 0 });
+
+ const added = add.mock.calls.filter(c => c[0] === 'touchend').length;
+ const removed = remove.mock.calls.filter(c => c[0] === 'touchend').length;
+ expect(added).toBeGreaterThan(0);
+ expect(removed).toBe(added);
+ } finally {
+ add.mockRestore();
+ remove.mockRestore();
+ if (hadPlatform) {
+ Object.defineProperty(navigator, 'platform', hadPlatform);
+ } else {
+ Reflect.deleteProperty(navigator, 'platform');
+ }
+ }
+ });
+ });
+
+ describe('shouldDrag gate', () => {
+ it('does not drag (or close) when dragging scrolled inner content', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const list = screen.getByTestId('scrollable');
+ makeScrollable(list, { scrollHeight: 500, clientHeight: 100, scrollTop: 50 });
+
+ drag(list, 0, 120, 60); // fast enough to flick, but scrollTop !== 0
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('does not drag from a [data-cl-drawer-no-drag] subtree', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ stubHeight(screen.getByRole('dialog'), 400);
+
+ drag(screen.getByTestId('nodrag'), 0, 200, 60);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it('does not drag from a ', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ stubHeight(screen.getByRole('dialog'), 400);
+
+ drag(screen.getByTestId('native-select'), 0, 200, 60);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it('suppresses drag during the open grace window', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ // settle:false => press immediately after open, within the grace window.
+ drag(popup, 0, 200, 200, { settle: false });
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('allows a downward drag at the top of scrollable inner content', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+ const list = screen.getByTestId('scrollable');
+ makeScrollable(list, { scrollHeight: 500, clientHeight: 100, scrollTop: 0 });
+
+ drag(list, 0, 120, 200); // scrollTop === 0 => the sheet drags
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('drags to dismiss when there is no scroll container', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ drag(screen.getByText('Drawer body content'), 0, 120, 200);
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('does not drag when a scrollable ancestor of the popup is scrolled', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+ makeScrollable(screen.getByTestId('scroll-ancestor'), { scrollHeight: 500, clientHeight: 100, scrollTop: 60 });
+
+ drag(screen.getByTestId('ancestor-item'), 0, 120, 200);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('ignores cross-axis (horizontal) jitter during a vertical drag', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(popup, { pointerId: 1, clientX: 0, clientY: 0, button: 0, pointerType: 'touch' });
+ const steps = 4;
+ for (let i = 1; i <= steps; i++) {
+ clock.t += 200 / steps;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientX: i % 2 ? 4 : -4, clientY: (120 * i) / steps });
+ }
+ fireEvent.pointerUp(popup, { pointerId: 1, clientX: 4, clientY: 120 });
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('does not drag from a native range input', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+
+ drag(screen.getByTestId('range'), 0, 200, 200);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('does not drag from a role=slider thumb', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+
+ drag(screen.getByTestId('slider'), 0, 200, 200);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('does not drag while an input has an active text selection', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ const input = screen.getByTestId('text-input');
+ input.focus();
+ input.setSelectionRange(0, 5);
+
+ drag(popup, 0, 200, 200);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('does not drag while a textarea has an active text selection', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ const textarea = screen.getByTestId('textarea');
+ textarea.focus();
+ textarea.setSelectionRange(0, 5);
+
+ drag(popup, 0, 200, 200);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('does not drag while a contenteditable has an active selection', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ selectText(screen.getByTestId('editable'));
+
+ drag(popup, 0, 200, 200);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('does not drag while regular DOM text is selected', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ selectText(screen.getByTestId('plain-text'));
+
+ drag(popup, 0, 200, 200);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+ });
+
+ describe('handleOnly', () => {
+ it('drags from the handle but not from the body', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 400);
+
+ // Body press: must not drag.
+ drag(screen.getByText('Drawer body content'), 0, 200, 200);
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+
+ // Handle press: drags and closes.
+ stubHeight(popup, 400);
+ drag(screen.getByTestId('handle'), 0, 200, 200);
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('snap points', () => {
+ // snapPoints [0.5, 1] @ 800px viewport => offset(0) = 400, offset(1) = 0.
+ const SNAP_POINTS = [0.5, 1];
+ const snapOffset = (el: HTMLElement) => el.style.getPropertyValue(DrawerCssVars.snapOffset);
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
+ });
+
+ it('rests at the last (most open) snap point by default', () => {
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ expect(snapOffset(popup)).toBe('0px');
+ expect(popup).toHaveAttribute('data-cl-snap', '1');
+ expect(popup).toHaveAttribute('data-cl-expanded', '');
+ });
+
+ it('positions at a controlled activeSnapPoint', () => {
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ expect(snapOffset(popup)).toBe('400px');
+ expect(popup).toHaveAttribute('data-cl-snap', '0');
+ expect(popup).not.toHaveAttribute('data-cl-expanded');
+ });
+
+ it('settles to the closest snap point on a slow release', () => {
+ const onActiveSnapPointChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+
+ drag(popup, 0, 260, 300); // pos 260 is closest to offset(0)=400
+
+ expect(onActiveSnapPointChange).toHaveBeenCalledWith(0);
+ expect(snapOffset(popup)).toBe('400px');
+ expect(popup).toHaveAttribute('data-cl-snap', '0');
+ });
+
+ it('fast upward flick steps to the next-higher snap point', () => {
+ const onActiveSnapPointChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+
+ drag(popup, 200, 0, 20); // fast flick up from snap 0
+
+ expect(onActiveSnapPointChange).toHaveBeenCalledWith(1);
+ expect(snapOffset(popup)).toBe('0px');
+ });
+
+ it('fast downward flick at the first snap point closes when dismissible', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+
+ drag(screen.getByRole('dialog'), 0, 200, 20); // fast flick down from snap 0
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('fast downward flick at the first snap point stays open when not dismissible', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+
+ drag(screen.getByRole('dialog'), 0, 200, 20);
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('does not dismiss on an upward drag at the full snap point', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ const list = screen.getByTestId('scrollable');
+ makeScrollable(list, { scrollHeight: 500, clientHeight: 100, scrollTop: 80 });
+
+ drag(list, 200, 0, 200); // upward swipe at the top (full) snap
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(swipeY(popup)).toBe('');
+ });
+
+ it('a fast upward flick after dragging down expands instead of dismissing', () => {
+ const onOpenChange = vi.fn();
+ const onActiveSnapPointChange = vi.fn();
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(popup, { pointerId: 1, clientY: 0, button: 0, pointerType: 'touch' });
+ clock.t += 200;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 }); // dragged down toward dismissal
+ clock.t += 10;
+ fireEvent.pointerMove(popup, { pointerId: 1, clientY: 40 }); // fast flick back up
+ fireEvent.pointerUp(popup, { pointerId: 1, clientY: 40 });
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ expect(onActiveSnapPointChange).toHaveBeenCalledWith(1);
+ expect(snapOffset(popup)).toBe('0px');
+ });
+
+ it('treats an empty snapPoints array as no snap points', () => {
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ expect(popup).not.toHaveAttribute('data-cl-snap');
+ expect(snapOffset(popup)).toBe('');
+ });
+
+ it('clamps an out-of-range controlled activeSnapPoint', () => {
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ expect(popup).toHaveAttribute('data-cl-snap', '1'); // clamped to lastIndex, not NaN
+ expect(snapOffset(popup)).toBe('0px');
+ });
+ });
+
+ describe('snap point lifecycle', () => {
+ const SNAP_POINTS = [0.5, 1];
+ const snapOffset = (el: HTMLElement) => el.style.getPropertyValue(DrawerCssVars.snapOffset);
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
+ });
+
+ it('returns to the default snap point after closing and reopening', async () => {
+ const user = userEvent.setup();
+ render( ); // default active = last (1)
+
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+ let popup = screen.getByRole('dialog');
+ expect(popup).toHaveAttribute('data-cl-snap', '1');
+
+ drag(popup, 0, 260, 300); // settle to snap 0 (closest to offset(0) = 400)
+ expect(popup).toHaveAttribute('data-cl-snap', '0');
+
+ await user.keyboard('{Escape}');
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+
+ popup = screen.getByRole('dialog');
+ expect(popup).toHaveAttribute('data-cl-snap', '1'); // reset to the default
+ expect(snapOffset(popup)).toBe('0px');
+ });
+
+ it('returns to a provided defaultActiveSnapPoint after closing', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+ let popup = screen.getByRole('dialog');
+ expect(popup).toHaveAttribute('data-cl-snap', '0');
+
+ drag(popup, 400, 0, 300); // move up to snap 1
+ expect(popup).toHaveAttribute('data-cl-snap', '1');
+
+ await user.keyboard('{Escape}');
+ await user.click(screen.getByRole('button', { name: 'Open drawer' }));
+
+ popup = screen.getByRole('dialog');
+ expect(popup).toHaveAttribute('data-cl-snap', '0'); // back to the provided default
+ });
+
+ it('does not reset the snap point when a close is canceled', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ // Controlled + always open: the close is canceled, so no reset happens.
+ render(
+ ,
+ );
+ const popup = screen.getByRole('dialog');
+ expect(popup).toHaveAttribute('data-cl-snap', '0');
+
+ drag(popup, 400, 0, 300); // move up to snap 1
+ expect(popup).toHaveAttribute('data-cl-snap', '1');
+
+ await user.keyboard('{Escape}');
+
+ expect(onOpenChange).toHaveBeenCalledWith(false); // the consumer was asked to close
+ expect(popup).toHaveAttribute('data-cl-snap', '1'); // but the snap point was not reset
+ });
+ });
+
+ describe('getSnapPointSwipeMovement', () => {
+ it('returns the raw movement when the drag does not overshoot the open edge', () => {
+ expect(getSnapPointSwipeMovement(100, -50)).toBe(-50);
+ expect(getSnapPointSwipeMovement(0, 20)).toBe(20);
+ });
+
+ it('returns the raw movement at the open edge (nextOffset === 0)', () => {
+ expect(getSnapPointSwipeMovement(100, -100)).toBe(-100);
+ });
+
+ it('square-root damps the movement once the drag overshoots the open edge', () => {
+ expect(getSnapPointSwipeMovement(0, -150)).toBeCloseTo(-Math.sqrt(150));
+ expect(getSnapPointSwipeMovement(100, -250)).toBeCloseTo(-Math.sqrt(150) - 100);
+ });
+ });
+
+ describe('safeCapture', () => {
+ it('no-ops when the pointer-capture method is unavailable', () => {
+ const el = document.createElement('div');
+ Object.defineProperty(el, 'setPointerCapture', { value: undefined, configurable: true });
+ expect(() => safeCapture(el, 1, 'setPointerCapture')).not.toThrow();
+ });
+
+ it('swallows a NotFoundError from releasing an uncaptured pointer', () => {
+ const el = document.createElement('div');
+ Object.defineProperty(el, 'releasePointerCapture', {
+ value: () => {
+ throw new DOMException('not found', 'NotFoundError');
+ },
+ configurable: true,
+ });
+ expect(() => safeCapture(el, 1, 'releasePointerCapture')).not.toThrow();
+ });
+ });
+
+ describe('virtual keyboard (repositionInputs)', () => {
+ class FakeVisualViewport extends EventTarget {
+ height = 800;
+ }
+ const original = Object.getOwnPropertyDescriptor(window, 'visualViewport');
+
+ function setVisualViewport(value: unknown) {
+ Object.defineProperty(window, 'visualViewport', { value, configurable: true, writable: true });
+ }
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
+ });
+ afterEach(() => {
+ if (original) {
+ Object.defineProperty(window, 'visualViewport', original);
+ }
+ });
+
+ it('is inert (no crash, no inline offset) when visualViewport is unavailable', () => {
+ setVisualViewport(null);
+ render( );
+ const popup = screen.getByRole('dialog');
+ expect(popup.style.bottom).toBe('');
+ });
+
+ it('lifts and caps the popup when the keyboard opens over a focused field', () => {
+ const vv = new FakeVisualViewport();
+ setVisualViewport(vv);
+ render( );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 900); // taller than the shrunken viewport
+
+ screen.getByTestId('field').focus();
+ vv.height = 500; // keyboard takes 300px
+ vv.dispatchEvent(new Event('resize'));
+
+ expect(popup.style.bottom).toBe('300px');
+ expect(popup.style.height).toBe('500px');
+ });
+
+ it('keeps the height cap stable across repeated resizes while the keyboard stays open', () => {
+ const vv = new FakeVisualViewport();
+ setVisualViewport(vv);
+ render( );
+ const popup = screen.getByRole('dialog');
+ stubMeasuredHeight(popup, 900); // natural height, taller than the shrunken viewport
+
+ screen.getByTestId('field').focus();
+ vv.height = 500;
+ vv.dispatchEvent(new Event('resize'));
+ expect(popup.style.height).toBe('500px');
+
+ // iOS fires `resize` repeatedly while the keyboard is open. The cap must not
+ // flip off just because the popup now measures at the capped height.
+ vv.dispatchEvent(new Event('resize'));
+ expect(popup.style.height).toBe('500px');
+ });
+
+ it('drops the lift when the keyboard closes', () => {
+ const vv = new FakeVisualViewport();
+ setVisualViewport(vv);
+ render( );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 900);
+
+ const field = screen.getByTestId('field');
+ field.focus();
+ vv.height = 500;
+ vv.dispatchEvent(new Event('resize'));
+ expect(popup.style.bottom).toBe('300px');
+
+ // Keyboard dismissed: focus falls back to `body` and the viewport grows again.
+ field.blur();
+ vv.height = 800;
+ vv.dispatchEvent(new Event('resize'));
+
+ expect(popup.style.bottom).toBe('');
+ expect(popup.style.height).toBe('');
+ });
+
+ it('does nothing when the focused element is not a text field', () => {
+ const vv = new FakeVisualViewport();
+ setVisualViewport(vv);
+ render( );
+ const popup = screen.getByRole('dialog');
+
+ screen.getByRole('button', { name: 'Close' }).focus();
+ vv.height = 500;
+ vv.dispatchEvent(new Event('resize'));
+
+ expect(popup.style.bottom).toBe('');
+ });
+
+ it('restores inline styles when repositioning is disabled', () => {
+ const vv = new FakeVisualViewport();
+ setVisualViewport(vv);
+ const { rerender } = render( );
+ const popup = screen.getByRole('dialog');
+ stubHeight(popup, 900);
+
+ screen.getByTestId('field').focus();
+ vv.height = 500;
+ vv.dispatchEvent(new Event('resize'));
+ expect(popup.style.bottom).toBe('300px');
+
+ rerender(
+ ,
+ );
+
+ expect(popup.style.bottom).toBe('');
+ expect(popup.style.height).toBe('');
+ });
+ });
+
+ describe('nested drawers', () => {
+ function NestedFixture() {
+ return (
+ clock.t}
+ >
+
+
+
+ Parent drawer
+ clock.t}>
+ Open child
+
+
+
+ Child drawer
+ Close child
+
+
+
+
+
+
+
+
+ );
+ }
+
+ it('marks the parent as nested-open and counts open children when a child opens', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const parentPopup = screen.getByRole('dialog'); // only the parent is open initially
+ expect(parentPopup).not.toHaveAttribute('data-cl-nested-drawer-open');
+
+ await user.click(screen.getByRole('button', { name: 'Open child' }));
+
+ expect(parentPopup).toHaveAttribute('data-cl-nested-drawer-open', '');
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedCount)).toBe('1');
+
+ const childPopup = screen.getByText('Child drawer').closest('[role="dialog"]');
+ expect(childPopup).toHaveAttribute('data-cl-nested', '');
+ expect(parentPopup).toHaveAttribute('data-cl-open', ''); // parent survives the child opening
+ });
+
+ it('clears the parent nested-open state when the child closes', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const parentPopup = screen.getByRole('dialog');
+ await user.click(screen.getByRole('button', { name: 'Open child' }));
+ expect(parentPopup).toHaveAttribute('data-cl-nested-drawer-open', '');
+
+ await user.click(screen.getByRole('button', { name: 'Close child' }));
+
+ expect(parentPopup).not.toHaveAttribute('data-cl-nested-drawer-open');
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedCount)).toBe('0');
+ });
+
+ it('does not count a Dialog opened inside a drawer as a nested drawer', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const parentPopup = screen.getByRole('dialog');
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedCount)).toBe('0');
+
+ await user.click(screen.getByRole('button', { name: 'Open dialog' }));
+
+ expect(screen.getByText('Inner dialog')).toBeInTheDocument();
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedCount)).toBe('0');
+ expect(parentPopup).not.toHaveAttribute('data-cl-nested-drawer-open');
+ });
+
+ function getChildPopup() {
+ const childPopup = screen.getByText('Child drawer').closest('[role="dialog"]');
+ if (!(childPopup instanceof HTMLElement)) {
+ throw new Error('expected the child drawer popup to be an HTMLElement');
+ }
+ return childPopup;
+ }
+
+ it('reports live progress while dragging and settles the parent to full (1) on a dismiss', async () => {
+ const user = userEvent.setup();
+ render( );
+ const parentPopup = screen.getByRole('dialog'); // only the parent is open initially
+ await user.click(screen.getByRole('button', { name: 'Open child' }));
+ const childPopup = getChildPopup();
+ stubHeight(childPopup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(childPopup, { pointerId: 1, clientY: 0, button: 0, isPrimary: true, pointerType: 'touch' });
+ clock.t += 50;
+ fireEvent.pointerMove(childPopup, { pointerId: 1, clientY: 200 }); // 200 / 400 = 0.5
+
+ expect(parentPopup).toHaveAttribute('data-cl-nested-drawer-swiping', '');
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('0.5');
+
+ // Fast, past-threshold release = dismiss. The parent settles toward full (1),
+ // the same direction the open-count drop takes it, so the styled scale never
+ // jumps backward.
+ fireEvent.pointerUp(childPopup, { pointerId: 1, clientY: 200 });
+ expect(parentPopup).not.toHaveAttribute('data-cl-nested-drawer-swiping');
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('1');
+ expect(parentPopup).not.toHaveAttribute('data-cl-nested-drawer-open'); // child dismissed
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedCount)).toBe('0');
+ });
+
+ it('settles the parent back to the scaled rest (0) when the child stays open on release', async () => {
+ const user = userEvent.setup();
+ render( );
+ const parentPopup = screen.getByRole('dialog');
+ await user.click(screen.getByRole('button', { name: 'Open child' }));
+ const childPopup = getChildPopup();
+ stubHeight(childPopup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(childPopup, { pointerId: 1, clientY: 0, button: 0, isPrimary: true, pointerType: 'touch' });
+ clock.t += 400; // slow drag => low release velocity
+ fireEvent.pointerMove(childPopup, { pointerId: 1, clientY: 40 }); // 40 < 400 * 0.25 threshold
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('0.1');
+
+ // Small, slow release = snap back. The child stays open, so the parent
+ // returns to its scaled-back rest (0) and remains nested-open.
+ fireEvent.pointerUp(childPopup, { pointerId: 1, clientY: 40 });
+ expect(parentPopup).not.toHaveAttribute('data-cl-nested-drawer-swiping');
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('0');
+ expect(parentPopup).toHaveAttribute('data-cl-nested-drawer-open', '');
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedCount)).toBe('1');
+ });
+
+ it('resets the parent progress to 0 when the next child opens after a dismiss', async () => {
+ const user = userEvent.setup();
+ render( );
+ const parentPopup = screen.getByRole('dialog');
+ await user.click(screen.getByRole('button', { name: 'Open child' }));
+ const childPopup = getChildPopup();
+ stubHeight(childPopup, 400);
+
+ // Dismiss the first child by dragging it down; progress parks at 1 (full).
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(childPopup, { pointerId: 1, clientY: 0, button: 0, isPrimary: true, pointerType: 'touch' });
+ clock.t += 50;
+ fireEvent.pointerMove(childPopup, { pointerId: 1, clientY: 300 });
+ fireEvent.pointerUp(childPopup, { pointerId: 1, clientY: 300 });
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('1');
+
+ // Opening the next child must re-scale the parent, not leave it parked at 1.
+ await user.click(screen.getByRole('button', { name: 'Open child' }));
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('0');
+ expect(parentPopup).toHaveAttribute('data-cl-nested-drawer-open', '');
+ });
+
+ it('clamps the reported nested progress to the 0..1 range', async () => {
+ const user = userEvent.setup();
+ render( );
+ const parentPopup = screen.getByRole('dialog'); // only the parent is open initially
+ await user.click(screen.getByRole('button', { name: 'Open child' }));
+ const childPopup = getChildPopup();
+ stubHeight(childPopup, 400);
+
+ clock.t += OPEN_GRACE_PERIOD + 50;
+ fireEvent.pointerDown(childPopup, { pointerId: 1, clientY: 0, button: 0, isPrimary: true, pointerType: 'touch' });
+
+ clock.t += 20;
+ fireEvent.pointerMove(childPopup, { pointerId: 1, clientY: 600 }); // past the sheet height
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('1');
+
+ clock.t += 20;
+ fireEvent.pointerMove(childPopup, { pointerId: 1, clientY: -100 }); // upward: no dismiss progress
+ expect(parentPopup.style.getPropertyValue(DrawerCssVars.nestedDragProgress)).toBe('0');
+
+ fireEvent.pointerUp(childPopup, { pointerId: 1, clientY: -100 });
+ });
+ });
+
+ describe('nested portals (form controls)', () => {
+ function DrawerWithSelect() {
+ return (
+ clock.t}
+ >
+
+
+
+ Drawer with select
+
+
+
+
+
+
+
+ A
+
+
+
+
+
+
+
+
+ );
+ }
+
+ it('opening a Select inside the drawer does not close the drawer', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick'));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('option', { name: 'A' }));
+
+ expect(screen.getByText('Drawer with select')).toBeInTheDocument();
+ });
+
+ it('Escape closes the listbox before the drawer', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick'));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ expect(screen.getByText('Drawer with select')).toBeInTheDocument();
+ });
+ });
+
+ describe('accessibility (axe)', () => {
+ it('has no violations when open', async () => {
+ render( );
+ expect(
+ await axe(document.body, {
+ rules: {
+ region: { enabled: false },
+ 'aria-command-name': { enabled: false },
+ 'aria-hidden-focus': { enabled: false },
+ },
+ }),
+ ).toHaveNoViolations();
+ });
+ });
+});
diff --git a/packages/headless/src/primitives/drawer/helpers.ts b/packages/headless/src/primitives/drawer/helpers.ts
new file mode 100644
index 00000000000..b3557c3231a
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/helpers.ts
@@ -0,0 +1,88 @@
+/**
+ * Pure helpers for the drag engine.
+ *
+ * NOTE: the live swipe amount is tracked in the engine's `curSwipe` ref (the
+ * single source of truth), never parsed back from `getComputedStyle().transform`.
+ * Because movement is driven through a CSS var, the composed matrix also carries
+ * the snap offset and any nested `scale()`, so reading it back would be wrong.
+ */
+
+/** ` ` values that are not free-text and therefore never pop the virtual keyboard. */
+const NON_TEXT_INPUT_TYPES = new Set([
+ 'button',
+ 'checkbox',
+ 'color',
+ 'file',
+ 'hidden',
+ 'image',
+ 'radio',
+ 'range',
+ 'reset',
+ 'submit',
+]);
+
+/** Logarithmic rubber-banding for over-drag past the open position. (vaul `dampenValue`) */
+export const dampen = (v: number): number => 8 * (Math.log(v + 1) - 2);
+
+export const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
+
+/**
+ * Resolves the vertical swipe movement for a snap point, applying square-root
+ * damping once the drag overshoots the fully-open edge (`nextOffset < 0`) so the
+ * sheet resists travelling past it. `baseOffset` is the resting offset of the
+ * active snap point (>= 0); `movementValue` is the signed live drag delta
+ * (positive downward). Returns the movement to add on top of `baseOffset`.
+ *
+ * A distinct formula from {@link dampen}: snap drawers use square-root overshoot
+ * damping (base-ui) while the plain rubber-band uses logarithmic damping (vaul).
+ */
+export function getSnapPointSwipeMovement(baseOffset: number, movementValue: number): number {
+ const nextOffset = baseOffset + movementValue;
+ if (nextOffset >= 0) {
+ return movementValue;
+ }
+ return -Math.sqrt(-nextOffset) - baseOffset;
+}
+
+/** Whether focusing `el` would summon the on-screen keyboard (a text field or contenteditable). */
+export function isInput(el: Element): boolean {
+ return (
+ (el instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(el.type)) ||
+ el instanceof HTMLTextAreaElement ||
+ (el instanceof HTMLElement && el.isContentEditable)
+ );
+}
+
+/**
+ * Best-effort iOS detection. Used only to work around iOS not dispatching
+ * `pointerup` after a scroll-cancelled gesture; never reached in happy-dom.
+ */
+export function isIOS(): boolean {
+ if (typeof navigator === 'undefined') {
+ return false;
+ }
+ return (
+ /iP(hone|ad|od)/.test(navigator.platform) ||
+ // iPadOS 13+ reports as a Mac, so additionally check for touch support.
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
+ );
+}
+
+/**
+ * Pointer capture that tolerates environments/states where it is unavailable.
+ * happy-dom may not implement `set/releasePointerCapture` (calling an absent
+ * method would throw `TypeError`), and releasing a pointer that was never
+ * captured throws `NotFoundError`; both are swallowed.
+ */
+export function safeCapture(el: Element, id: number, method: 'setPointerCapture' | 'releasePointerCapture'): void {
+ if (typeof el[method] !== 'function') {
+ return;
+ }
+ try {
+ el[method](id);
+ } catch (e) {
+ if (!(e instanceof DOMException && e.name === 'NotFoundError')) {
+ throw e;
+ }
+ }
+}
diff --git a/packages/headless/src/primitives/drawer/index.ts b/packages/headless/src/primitives/drawer/index.ts
new file mode 100644
index 00000000000..87ad1d67951
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/index.ts
@@ -0,0 +1,22 @@
+export * as Drawer from './parts';
+
+export { useDrawerContext } from './drawer-context';
+export type { DrawerContextValue } from './drawer-context';
+
+export { createDrawerHandle } from './drawer-handle';
+export type { DrawerHandle } from './drawer-handle';
+
+export { DrawerCssVars, DrawerAttrs, registerDrawerCssVars } from './css-vars';
+
+export type {
+ DrawerBackdropProps,
+ DrawerCloseProps,
+ DrawerDescriptionProps,
+ DrawerHandleProps,
+ DrawerPopupProps,
+ DrawerPortalProps,
+ DrawerProps,
+ DrawerTitleProps,
+ DrawerTriggerProps,
+ DrawerViewportProps,
+} from './parts';
diff --git a/packages/headless/src/primitives/drawer/parts.ts b/packages/headless/src/primitives/drawer/parts.ts
new file mode 100644
index 00000000000..3a008d339be
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/parts.ts
@@ -0,0 +1,10 @@
+export { type DrawerProps, DrawerRoot as Root } from './drawer-root';
+export { type DrawerTriggerProps, DrawerTrigger as Trigger } from './drawer-trigger';
+export { type DrawerPortalProps, DrawerPortal as Portal } from './drawer-portal';
+export { type DrawerBackdropProps, DrawerBackdrop as Backdrop } from './drawer-backdrop';
+export { type DrawerViewportProps, DrawerViewport as Viewport } from './drawer-viewport';
+export { type DrawerPopupProps, DrawerPopup as Popup } from './drawer-popup';
+export { type DrawerHandleProps, DrawerHandleGrip as Handle } from './drawer-handle-grip';
+export { type DrawerTitleProps, DrawerTitle as Title } from './drawer-title';
+export { type DrawerDescriptionProps, DrawerDescription as Description } from './drawer-description';
+export { type DrawerCloseProps, DrawerClose as Close } from './drawer-close';
diff --git a/packages/headless/src/primitives/drawer/use-drawer-drag.ts b/packages/headless/src/primitives/drawer/use-drawer-drag.ts
new file mode 100644
index 00000000000..bb0047ed9bb
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/use-drawer-drag.ts
@@ -0,0 +1,310 @@
+'use client';
+
+import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react';
+
+import {
+ CLOSE_THRESHOLD,
+ MIN_SAMPLE_MS,
+ OPEN_GRACE_PERIOD,
+ RELEASE_VEL_MAX_AGE_MS,
+ SCROLL_LOCK_TIMEOUT,
+ VELOCITY_THRESHOLD,
+} from './constants';
+import { DrawerAttrs, DrawerCssVars } from './css-vars';
+import { clamp, dampen, isIOS, safeCapture } from './helpers';
+import type { SnapController } from './use-snap-points';
+
+export interface UseDrawerDragOptions {
+ popupRef: React.RefObject;
+ open: boolean;
+ /** When true (default), a downward release past threshold closes the drawer. */
+ dismissible: boolean;
+ /** When true, only a press starting on `[data-cl-drawer-handle]` drags. */
+ handleOnly: boolean;
+ snapPoints?: number[];
+ snap: SnapController | null;
+ /** Closes the drawer (exit is owned by `useTransition` + the styled ending-style). */
+ close: () => void;
+ /** Injectable clock so velocity is deterministic under test. */
+ now: () => number;
+ setVar: (name: string, value: string) => void;
+ /** The single writer of the live swipe-y (keeps the CSS var and `curSwipe` in lockstep). */
+ setSwipe: (px: number) => void;
+ /** Source of truth for the current swipe-y, read by drag decisions. */
+ curSwipe: React.MutableRefObject;
+ /** When this drawer is nested, report its live 0..1 dismiss progress so the parent can scale in. */
+ onNestedDrag?: (progress: number) => void;
+ /** When this drawer is nested, signal the parent that the drag ended (and whether this drawer stays open). */
+ onNestedRelease?: (childOpen: boolean) => void;
+}
+
+export interface UseDrawerDragReturn {
+ onPointerDown: (e: ReactPointerEvent) => void;
+ onPointerMove: (e: ReactPointerEvent) => void;
+ onPointerUp: (e: ReactPointerEvent) => void;
+ onPointerCancel: (e: ReactPointerEvent) => void;
+ isDragging: boolean;
+}
+
+/**
+ * Pointer/transform drag engine for a bottom sheet (Y axis only). Down-to-dismiss
+ * with velocity and distance thresholds, an inner-scroll-aware gate, and snap-point
+ * delegation. All per-gesture state lives in refs; only `isDragging` is React state.
+ *
+ * Movement is written via the `swipeY` CSS var (no direct `transform`), so the
+ * `curSwipe` ref — not `getComputedStyle` — is the source of truth for decisions.
+ */
+export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
+ const { open, now } = opts;
+ const [isDragging, setIsDragging] = useState(false);
+
+ // Per-gesture state. Only `isDragging` (above) drives rendering.
+ const pid = useRef(null);
+ const startY = useRef(0);
+ const allowed = useRef(false); // committed to dragging the sheet this gesture
+ const draggingRef = useRef(false); // synchronous mirror of isDragging for the handler guards
+ const lastScrollAt = useRef(0);
+ const sheetH = useRef(0);
+ const openTime = useRef(0);
+ const lastSample = useRef({ y: 0, t: 0 });
+ const vel = useRef(0); // px/ms, signed
+ const captured = useRef(null);
+ // Removes the current iOS `touchend` fallback listener (see `onPointerDown`),
+ // so it never outlives its gesture or piles up across gestures.
+ const removeTouchEnd = useRef<(() => void) | null>(null);
+
+ // Latest options, read at event time so the handlers can stay referentially stable.
+ const cfg = useRef(opts);
+ cfg.current = opts;
+
+ // Record when the drawer opened so the grace window can suppress drag while the
+ // enter animation settles.
+ useEffect(() => {
+ if (open) {
+ openTime.current = now();
+ }
+ }, [open, now]);
+
+ // Drop any pending iOS touchend fallback if the drawer unmounts mid-gesture.
+ useEffect(() => () => removeTouchEnd.current?.(), []);
+
+ const shouldDrag = useCallback((target: HTMLElement, down: boolean): boolean => {
+ const { now: clock, curSwipe, snap } = cfg.current;
+ // Total displacement from fully-open = resting snap offset + live drag delta.
+ // Summed from our own authored values, never parsed from getComputedStyle.
+ const swipe = curSwipe.current + (snap?.restOffset ?? 0);
+
+ if (target.tagName === 'SELECT') {
+ return false;
+ }
+ // Native range inputs and custom slider thumbs consume the drag themselves.
+ if (target.closest('input[type="range"], [role="slider"]')) {
+ return false;
+ }
+ if (target.closest(`[${DrawerAttrs.noDrag}]`)) {
+ return false;
+ }
+ // A text selection is in progress (contenteditable / regular DOM text).
+ if (window.getSelection()?.toString().length) {
+ return false;
+ }
+ // A focused input/textarea with a non-collapsed selection: dragging is
+ // adjusting a selection handle, not the sheet. (input/textarea selections
+ // are not reflected in `window.getSelection()`.)
+ const active = document.activeElement;
+ if (
+ (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) &&
+ active.selectionStart !== active.selectionEnd
+ ) {
+ return false;
+ }
+ if (clock() - openTime.current < OPEN_GRACE_PERIOD) {
+ return false;
+ }
+ // Already dragged down this gesture — keep going.
+ if (swipe > 0) {
+ return true;
+ }
+ // Suppress drag briefly after inner content was scrolled.
+ if (lastScrollAt.current && clock() - lastScrollAt.current < SCROLL_LOCK_TIMEOUT && swipe === 0) {
+ lastScrollAt.current = clock();
+ return false;
+ }
+ // Upward at rest: let inner content scroll instead.
+ if (!down) {
+ return false;
+ }
+ for (let el: HTMLElement | null = target; el; el = el.parentElement) {
+ if (el.scrollHeight > el.clientHeight) {
+ if (el.scrollTop !== 0) {
+ lastScrollAt.current = clock();
+ return false; // scrolling within inner content
+ }
+ if (el.getAttribute('role') === 'dialog') {
+ return true; // reached the sheet boundary
+ }
+ }
+ }
+ return true;
+ }, []);
+
+ const sample = useCallback((y: number, t: number): void => {
+ const dt = Math.max(t - lastSample.current.t, MIN_SAMPLE_MS);
+ vel.current = (y - lastSample.current.y) / dt;
+ lastSample.current = { y, t };
+ }, []);
+
+ const onPointerDown = useCallback((e: ReactPointerEvent): void => {
+ const { popupRef, dismissible, handleOnly, snapPoints, now: clock } = cfg.current;
+ if (e.pointerType === 'mouse' && e.button !== 0) {
+ return;
+ }
+ if (!dismissible && !snapPoints) {
+ return; // nothing a drag could accomplish
+ }
+ const popup = popupRef.current;
+ if (!popup || !popup.contains(e.target as Node)) {
+ return;
+ }
+ if (handleOnly && !(e.target as HTMLElement).closest(`[${DrawerAttrs.handle}]`)) {
+ return;
+ }
+
+ sheetH.current = popup.getBoundingClientRect().height;
+ pid.current = e.pointerId;
+ startY.current = e.clientY;
+ const t = clock();
+ lastSample.current = { y: e.clientY, t };
+ vel.current = 0;
+ allowed.current = false;
+ draggingRef.current = true;
+ setIsDragging(true);
+
+ // Capture the actual target (not the popup) so a click on an inner control
+ // still lands on it; the popup handler keeps receiving bubbled moves. (vaul)
+ const target = e.target as Element;
+ captured.current = target;
+ safeCapture(target, e.pointerId, 'setPointerCapture');
+
+ // iOS doesn't dispatch pointerup after a scroll-cancelled gesture, so reset
+ // `allowed` on touchend. Track the listener (and drop any stale one from a
+ // prior gesture that never fired) so it's removed on release/unmount instead
+ // of leaking on `window`.
+ if (isIOS()) {
+ removeTouchEnd.current?.();
+ const onTouchEnd = (): void => {
+ allowed.current = false;
+ removeTouchEnd.current = null;
+ };
+ window.addEventListener('touchend', onTouchEnd, { once: true });
+ removeTouchEnd.current = () => window.removeEventListener('touchend', onTouchEnd);
+ }
+ }, []);
+
+ const onPointerMove = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (!draggingRef.current || e.pointerId !== pid.current) {
+ return;
+ }
+ const { snapPoints, snap, setSwipe, setVar, now: clock, onNestedDrag } = cfg.current;
+ const dist = e.clientY - startY.current; // positive is downward
+ const down = dist > 0;
+
+ if (!allowed.current && !shouldDrag(e.target as HTMLElement, down)) {
+ return;
+ }
+ allowed.current = true;
+ sample(e.clientY, clock());
+
+ // Nested: hand the parent our normalized downward progress so it can scale in.
+ onNestedDrag?.(clamp(dist / sheetH.current, 0, 1));
+
+ if (snapPoints && snap) {
+ snap.onDrag(dist);
+ return;
+ }
+ if (!down) {
+ // Rubber-band over-drag past the open position; never moves below rest.
+ setSwipe(Math.min(-dampen(-dist), 0));
+ return;
+ }
+ setSwipe(dist);
+ setVar(DrawerCssVars.swipeProgress, String(Math.min(dist / sheetH.current, 1)));
+ },
+ [shouldDrag, sample],
+ );
+
+ const onRelease = useCallback((e: ReactPointerEvent): void => {
+ if (!draggingRef.current) {
+ return;
+ }
+ // Normal release: the iOS touchend fallback is no longer needed.
+ removeTouchEnd.current?.();
+ removeTouchEnd.current = null;
+ const {
+ snapPoints,
+ snap,
+ dismissible,
+ close,
+ setVar,
+ setSwipe,
+ now: clock,
+ curSwipe,
+ onNestedRelease,
+ } = cfg.current;
+
+ if (captured.current && pid.current !== null) {
+ safeCapture(captured.current, pid.current, 'releasePointerCapture');
+ }
+ captured.current = null;
+ setIsDragging(false);
+ draggingRef.current = false;
+ const didDrag = allowed.current;
+ allowed.current = false;
+ pid.current = null;
+
+ // Never committed to dragging the sheet (e.g. scrolled inner content). Do not
+ // close — releasing a fast inner-scroll must not be read as a flick-to-dismiss.
+ if (!didDrag) {
+ return;
+ }
+
+ const reset = (): void => {
+ setSwipe(0);
+ setVar(DrawerCssVars.swipeProgress, '0');
+ };
+
+ const swipe = curSwipe.current;
+ // Signed (positive is downward). Keeping the sign means a fast *upward* flick
+ // that ends net-downward isn't mistaken for a downward dismiss.
+ const v = clock() - lastSample.current.t <= RELEASE_VEL_MAX_AGE_MS ? vel.current : 0;
+ // Faster flick => shorter exit; the styled layer reads this to scale duration.
+ setVar(DrawerCssVars.swipeStrength, String(clamp(1 - Math.abs(v), 0.1, 1)));
+
+ if (snapPoints && snap) {
+ const childOpen = snap.onRelease({ dist: e.clientY - startY.current, v, dismissible, close });
+ onNestedRelease?.(childOpen);
+ return;
+ }
+
+ // Dismiss only on a net-downward release that is either a flick or past the
+ // distance threshold; otherwise snap back. A nesting parent is told which way
+ // this went so it can settle its scale in one direction (no flicker).
+ const dismiss =
+ e.clientY >= startY.current && (v > VELOCITY_THRESHOLD || swipe >= sheetH.current * CLOSE_THRESHOLD);
+ if (dismiss) {
+ close();
+ } else {
+ reset();
+ }
+ onNestedRelease?.(!dismiss);
+ }, []);
+
+ return {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp: onRelease,
+ onPointerCancel: onRelease,
+ isDragging,
+ };
+}
diff --git a/packages/headless/src/primitives/drawer/use-reposition-inputs.ts b/packages/headless/src/primitives/drawer/use-reposition-inputs.ts
new file mode 100644
index 00000000000..b211ba8f27c
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/use-reposition-inputs.ts
@@ -0,0 +1,74 @@
+'use client';
+
+import { useEffect } from 'react';
+
+import { isInput } from './helpers';
+
+export interface UseRepositionInputsOptions {
+ /** `repositionInputs && open` — the hook is inert unless this is true. */
+ enabled: boolean;
+ popupRef: React.RefObject;
+}
+
+/**
+ * Keeps a focused text field visible above the virtual keyboard. When the
+ * keyboard opens, `visualViewport` shrinks; we lift the popup by that amount and
+ * cap its height to the visible area. Inert where `visualViewport` is
+ * unavailable (e.g. desktop, happy-dom) and restores inline styles on cleanup.
+ *
+ * iOS hardening (the pre-focus `translateY` trick and a `focus` override) and
+ * snap-point-aware offsets are deferred — see the README follow-ups.
+ */
+export function useRepositionInputs({ enabled, popupRef }: UseRepositionInputsOptions): void {
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+ const viewport = typeof window !== 'undefined' ? window.visualViewport : null;
+ if (!viewport) {
+ return;
+ }
+
+ // Track the element we actually mutated so cleanup restores exactly that one
+ // (rather than reading the ref at cleanup time, when it may have changed).
+ let touched: HTMLElement | null = null;
+
+ const onResize = (): void => {
+ const popup = popupRef.current;
+ if (!popup) {
+ return;
+ }
+ const active = document.activeElement;
+ if (!active || !isInput(active)) {
+ // The keyboard has closed (focus usually falls back to `body`); drop the
+ // lift we applied so the sheet doesn't stay raised until unmount.
+ if (touched) {
+ touched.style.height = '';
+ touched.style.bottom = '';
+ touched = null;
+ }
+ return;
+ }
+ const keyboardHeight = window.innerHeight - viewport.height;
+ // Measure the *natural* height by first clearing any cap we applied on a
+ // prior resize. Reading `getBoundingClientRect` while our own cap is in
+ // place would report the capped height, so the cap would flip off on the
+ // next resize (and back on the one after), thrashing the sheet height while
+ // the keyboard stays open.
+ popup.style.height = '';
+ const popupHeight = popup.getBoundingClientRect().height;
+ popup.style.height = popupHeight > viewport.height ? `${viewport.height}px` : '';
+ popup.style.bottom = `${Math.max(keyboardHeight, 0)}px`;
+ touched = popup;
+ };
+
+ viewport.addEventListener('resize', onResize);
+ return () => {
+ viewport.removeEventListener('resize', onResize);
+ if (touched) {
+ touched.style.height = '';
+ touched.style.bottom = '';
+ }
+ };
+ }, [enabled, popupRef]);
+}
diff --git a/packages/headless/src/primitives/drawer/use-snap-points.ts b/packages/headless/src/primitives/drawer/use-snap-points.ts
new file mode 100644
index 00000000000..b3ae101e037
--- /dev/null
+++ b/packages/headless/src/primitives/drawer/use-snap-points.ts
@@ -0,0 +1,171 @@
+'use client';
+
+import { useCallback, useEffect, useMemo } from 'react';
+
+import { useControllableState } from '../../hooks/use-controllable-state';
+import { SNAP_SKIP_VELOCITY } from './constants';
+import { DrawerCssVars } from './css-vars';
+import { clamp, getSnapPointSwipeMovement } from './helpers';
+
+export interface SnapReleaseArgs {
+ /** Net pointer delta on the Y axis over the gesture (`endY - startY`); positive is downward. */
+ dist: number;
+ /** Signed release velocity, px/ms; positive is downward. */
+ v: number;
+ dismissible: boolean;
+ close: () => void;
+}
+
+export interface SnapController {
+ onDrag: (dist: number) => void;
+ /** Settles to a snap point (or dismisses). Returns whether the drawer stays open. */
+ onRelease: (args: SnapReleaseArgs) => boolean;
+ snapTo: (index: number) => void;
+ activeIndex: number;
+ setActiveIndex: (index: number) => void;
+ /** Resting `translateY` of the active snap point — the sheet's displacement before any live drag. */
+ restOffset: number;
+ /** True when resting at the largest (full-height) snap point. */
+ expanded: boolean;
+}
+
+export interface UseSnapPointsOptions {
+ /** Ascending fractions (0..1) of the viewport the drawer rests at. */
+ snapPoints?: number[];
+ /** Controlled active index. */
+ activeSnapPoint?: number;
+ /** Uncontrolled initial active index. Defaults to the last (most open) point. */
+ defaultActiveSnapPoint?: number;
+ onActiveSnapPointChange?: (index: number) => void;
+ setVar: (name: string, value: string) => void;
+ setSwipe: (px: number) => void;
+ /** Current open state; on close the uncontrolled active index resets to the default. */
+ open: boolean;
+}
+
+/**
+ * Snap-point geometry + release logic. `offset(i)` is the resting `translateY`
+ * that leaves `snapPoints[i]` of the viewport visible (0 = fully open). Returns
+ * `null` when no snap points are configured.
+ *
+ * The drag engine drives this: `onDrag` while the finger moves, `onRelease` when
+ * it lifts. `snapTo` writes the rest `snapOffset` var and updates the active
+ * index (which fires `onActiveSnapPointChange`).
+ */
+export function useSnapPoints(opts: UseSnapPointsOptions): SnapController | null {
+ const { snapPoints, setVar, setSwipe, onActiveSnapPointChange, open } = opts;
+ // An empty array is treated as "no snap points" so `lastIndex` never goes
+ // negative (which would seed the state with -1 and emit `NaNpx` offsets).
+ const hasSnapPoints = !!snapPoints && snapPoints.length > 0;
+ const lastIndex = hasSnapPoints ? snapPoints.length - 1 : 0;
+ // Externally supplied indices are clamped to a valid, integral snap point.
+ const clampIndex = (i: number): number => clamp(Math.round(i), 0, lastIndex);
+ const isControlled = opts.activeSnapPoint !== undefined;
+ const defaultIndex = clampIndex(opts.defaultActiveSnapPoint ?? lastIndex);
+
+ const [activeIndex, setActiveIndex] = useControllableState(
+ opts.activeSnapPoint === undefined ? undefined : clampIndex(opts.activeSnapPoint),
+ defaultIndex,
+ onActiveSnapPointChange,
+ );
+
+ // On close, an uncontrolled drawer returns to its default snap point so the
+ // next open starts fresh. A canceled close keeps `open` true, so no reset.
+ useEffect(() => {
+ if (!open && !isControlled && activeIndex !== defaultIndex) {
+ setActiveIndex(defaultIndex);
+ }
+ }, [open, isControlled, activeIndex, defaultIndex, setActiveIndex]);
+
+ const offset = useCallback(
+ (i: number): number => {
+ // `offset` is read during render (via `restOffset` below), so it must be
+ // SSR-safe: `window` is absent on the server. Return 0 (fully open) until
+ // the client can measure; the popup's mount-effect writes the real offset.
+ if (!snapPoints || snapPoints.length === 0 || typeof window === 'undefined') {
+ return 0;
+ }
+ const vh = window.innerHeight;
+ return vh - snapPoints[i] * vh;
+ },
+ [snapPoints],
+ );
+
+ // The resting `snapOffset` var is applied by `Drawer.Popup` (it owns the ref
+ // and is guaranteed mounted), covering the initial position and controlled
+ // `activeSnapPoint` changes; `snapTo` writes it eagerly during a release.
+ // Resize is not tracked yet — see the README follow-ups.
+
+ const snapTo = useCallback(
+ (index: number): void => {
+ setSwipe(0);
+ setVar(DrawerCssVars.snapOffset, `${offset(index)}px`);
+ setActiveIndex(index);
+ },
+ [setSwipe, setVar, offset, setActiveIndex],
+ );
+
+ const onDrag = useCallback(
+ (dist: number): void => {
+ // 1:1 with the finger, except past the fully-open edge where the movement
+ // is square-root damped so the sheet resists overshooting.
+ setSwipe(getSnapPointSwipeMovement(offset(activeIndex), dist));
+ },
+ [offset, activeIndex, setSwipe],
+ );
+
+ const onRelease = useCallback(
+ ({ dist, v, dismissible, close }: SnapReleaseArgs): boolean => {
+ const pos = offset(activeIndex) + dist;
+ // Direction comes from the release velocity (falling back to net distance
+ // when the gesture ended at rest), so reversing course before lifting —
+ // drag down, then flick up — settles upward rather than dismissing.
+ const speed = Math.abs(v);
+ const down = v === 0 ? dist > 0 : v > 0;
+
+ // Fast flick: skip straight to the neighbouring snap point (or dismiss).
+ if (speed > SNAP_SKIP_VELOCITY) {
+ if (down) {
+ if (activeIndex === 0) {
+ if (dismissible) {
+ close();
+ return false;
+ }
+ snapTo(0);
+ } else {
+ snapTo(activeIndex - 1);
+ }
+ } else {
+ snapTo(Math.min(activeIndex + 1, lastIndex));
+ }
+ return true;
+ }
+
+ // Otherwise settle to the closest snap point.
+ let closest = 0;
+ for (let i = 1; i <= lastIndex; i++) {
+ if (Math.abs(offset(i) - pos) < Math.abs(offset(closest) - pos)) {
+ closest = i;
+ }
+ }
+ snapTo(closest);
+ return true;
+ },
+ [offset, activeIndex, lastIndex, snapTo],
+ );
+
+ const controller = useMemo(
+ () => ({
+ onDrag,
+ onRelease,
+ snapTo,
+ activeIndex,
+ setActiveIndex,
+ restOffset: offset(activeIndex),
+ expanded: activeIndex === lastIndex,
+ }),
+ [onDrag, onRelease, snapTo, activeIndex, setActiveIndex, offset, lastIndex],
+ );
+
+ return hasSnapPoints ? controller : null;
+}
diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts
index 153d0aa443b..3b8007c00ff 100644
--- a/packages/headless/vite.config.ts
+++ b/packages/headless/vite.config.ts
@@ -21,6 +21,7 @@ export default defineConfig({
'primitives/autocomplete/index': 'src/primitives/autocomplete/index.ts',
'primitives/collapsible/index': 'src/primitives/collapsible/index.ts',
'primitives/dialog/index': 'src/primitives/dialog/index.ts',
+ 'primitives/drawer/index': 'src/primitives/drawer/index.ts',
'utils/index': 'src/utils/index.ts',
'hooks/index': 'src/hooks/index.ts',
'hooks/use-controllable-state': 'src/hooks/use-controllable-state.ts',
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 66c8423ae2a..3dec740e723 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -39,6 +39,7 @@ const docModules: Record> = {
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
+ drawer: dynamic(() => import('../stories/drawer.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index ff0ee8b6f6a..d1f80029f50 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -15,6 +15,7 @@ import {
import { Default as DestructiveDefault, meta as destructiveMeta } from '../stories/destructive.stories';
import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories';
import { meta as dialogMeta } from '../stories/dialog.stories';
+import { meta as drawerMeta } from '../stories/drawer.stories';
import {
Default as HeadingDefault,
Intents as HeadingIntents,
@@ -106,6 +107,7 @@ const accordionModule: StoryModule = { meta: accordionMeta };
const autocompleteModule: StoryModule = { meta: autocompleteMeta };
const collapsibleModule: StoryModule = { meta: collapsibleMeta };
const dialogModule: StoryModule = { meta: dialogMeta };
+const drawerModule: StoryModule = { meta: drawerMeta };
const menuModule: StoryModule = { meta: menuMeta };
const popoverModule: StoryModule = { meta: popoverMeta };
const selectModule: StoryModule = { meta: selectMeta };
@@ -138,6 +140,7 @@ export const registry: StoryModule[] = [
autocompleteModule,
collapsibleModule,
dialogModule,
+ drawerModule,
menuModule,
popoverModule,
selectModule,
diff --git a/packages/swingset/src/stories/drawer.mdx b/packages/swingset/src/stories/drawer.mdx
new file mode 100644
index 00000000000..76b6cfc9730
--- /dev/null
+++ b/packages/swingset/src/stories/drawer.mdx
@@ -0,0 +1,207 @@
+import * as DrawerStories from './drawer.stories';
+
+# Drawer
+
+A modal bottom sheet that slides up from the bottom edge and is dismissed by dragging it
+down, from `@clerk/headless`. It is a **headless** primitive: it supplies open state,
+portalling, an overlay surface, a scroll-locked viewport, focus management, dismissal
+(drag / outside press / Escape), optional snap points, virtual-keyboard awareness, nesting,
+and ARIA wiring, but ships **no styles** — you bring your own CSS by targeting the
+`data-cl-*` state attributes each part emits and composing the raw `--cl-drawer-*` custom
+properties it writes. It reuses the same Floating UI infrastructure as `Dialog` with a
+hand-rolled drag engine layered on top; prefer `Dialog` for centered modals with no drag.
+
+## Example
+
+The demo below is intentionally unstyled — it renders the raw primitive so you can see its
+behavior and ARIA wiring. Open it, then drag the sheet down, press Escape, or click outside
+the popup to dismiss.
+
+
+
+## Usage
+
+```tsx
+import { Drawer } from '@clerk/headless/drawer';
+
+
+ Open
+
+
+
+
+
+ Sheet title
+ Optional description.
+ Sheet content.
+ Close
+
+
+
+ ;
+```
+
+### Controlled
+
+```tsx
+const [open, setOpen] = useState(false);
+
+
+ {/* trigger + portal */}
+ ;
+```
+
+### Detached trigger
+
+A trigger rendered **outside** `Drawer.Root` can drive it through a shared handle.
+`handle.open()` / `handle.close()` / `handle.toggle()` work imperatively, and
+`handle.isOpen` / `handle.subscribe(cb)` make it `useSyncExternalStore`-compatible.
+
+```tsx
+import { createDrawerHandle } from '@clerk/headless/drawer';
+
+const handle = createDrawerHandle();
+
+<>
+ Open from anywhere
+ {/* portal */}
+>;
+```
+
+### Snap points
+
+Ascending viewport fractions the sheet can rest at (`1` = full height). A slow release
+settles to the nearest point; a fast flick steps one point (up = larger, down = smaller, or
+dismiss from the first point when `dismissible`).
+
+```tsx
+
+ {/* portal */}
+
+```
+
+### Handle-only dragging
+
+Only a press starting on `Drawer.Handle` initiates the drag; the body scrolls normally.
+
+```tsx
+{/* portal */}
+```
+
+## Parts
+
+| Part | Default Element | Description |
+| -------------------- | --------------- | ------------------------------------------------------------------ |
+| `Drawer.Root` | none (context) | Owns open, drag, snap, and nesting state plus the ARIA ids |
+| `Drawer.Trigger` | `` | Opens the drawer on click (in-tree, or via a detached `handle`) |
+| `Drawer.Portal` | none (portal) | Portals its children; renders nothing until mounted |
+| `Drawer.Backdrop` | `` | Semi-transparent overlay surface behind the sheet |
+| `Drawer.Viewport` | `
` | Fixed full-viewport container; owns body scroll lock |
+| `Drawer.Popup` | `
` | The sheet (`role="dialog"`); hosts the drag gesture and focus trap |
+| `Drawer.Handle` | `
` | Visual drag grip; the hit-test target when `handleOnly` |
+| `Drawer.Title` | `
` | Heading; wired to the popup's `aria-labelledby` |
+| `Drawer.Description` | ` ` | Description; wired to the popup's `aria-describedby` |
+| `Drawer.Close` | `` | Closes the drawer on click |
+
+All rendered parts accept a `render` prop for polymorphic rendering and standard HTML
+attributes for their default element. Compound parts throw if used outside `Drawer.Root`.
+`Drawer.Handle` is presentational (no ARIA role); keyboard users dismiss via `Escape` or
+`Drawer.Close`. Unlike `Dialog`, `autoFocus` defaults to `false` so opening on touch does
+not summon the virtual keyboard.
+
+## Props
+
+### `Drawer.Root`
+
+| Prop | Type | Default | Description |
+| ------------------------------------ | ------------------------------------ | ------------------ | ----------------------------------------------- |
+| open | boolean | — | Controlled open state |
+| defaultOpen | boolean | false | Initial open state (uncontrolled) |
+| onOpenChange | (open: boolean) => void | — | Called when the open state changes |
+| modal | boolean | true | Trap focus and make the rest of the page inert |
+| dismissible | boolean | true | Allow drag / outside press / Escape to close |
+| handleOnly | boolean | false | Only `Drawer.Handle` starts a drag |
+| handle | DrawerHandle | — | Shared handle for a detached trigger |
+| snapPoints | number[] | — | Ascending viewport fractions (0..1) to rest at |
+| activeSnapPoint | number | — | Controlled active snap index |
+| defaultActiveSnapPoint | number | last | Uncontrolled initial active snap index |
+| onActiveSnapPointChange | (index: number) => void | — | Called when the active snap index changes |
+| repositionInputs | boolean | true | Keep a focused field above the virtual keyboard |
+| autoFocus | boolean | false | Move focus into the sheet on open |
+
+### `Drawer.Trigger`
+
+| Prop | Type | Default | Description |
+| ------------------- | ------------------------- | ------- | ---------------------------------------------------------------- |
+| handle | DrawerHandle | — | Drive a detached handle instead of the surrounding `Drawer.Root` |
+
+### `Drawer.Viewport`
+
+| Prop | Type | Default | Description |
+| ----------------------- | -------------------- | ----------------- | ----------------------------------------- |
+| lockScroll | boolean | true | Lock body scroll while the drawer is open |
+
+`Drawer.Portal` accepts a `root` container; `Drawer.Backdrop`, `Drawer.Popup`,
+`Drawer.Handle`, `Drawer.Title`, `Drawer.Description`, and `Drawer.Close` take no
+additional props beyond standard HTML attributes for their default element.
+
+## Styling
+
+The headless parts don't emit `data-cl-slot` — slot identity is applied by the styled
+(Mosaic) layer. Target a part with your own class (or `render` prop) and combine it with the
+`data-cl-*` state attributes each part emits:
+
+| Attribute | Applies To | Description |
+| ------------------------------------------------- | ---------------------------------- | --------------------------------------------------- |
+| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Present while open / closed (still mounted exiting) |
+| `data-cl-starting-style` / `data-cl-ending-style` | Backdrop, Viewport, Popup | Present on the entering / exiting frame |
+| `data-cl-swiping` | Popup, Backdrop | Present while a drag is in progress |
+| `data-cl-snap` | Popup | The active snap index |
+| `data-cl-expanded` | Popup | Present when resting at the full-height snap |
+| `data-cl-nested` | Popup | This drawer is itself nested |
+| `data-cl-nested-drawer-open` | Popup | A nested child drawer is open |
+| `data-cl-drawer-handle` | Handle | Grip / `handleOnly` hit-test target |
+| `data-cl-drawer-no-drag` | (consumer-set) | Opt a subtree out of the drag gesture |
+
+The drag engine and snap layer write raw inputs as CSS custom properties on `Drawer.Popup`;
+the styled layer composes them into the actual `transform` / `opacity` chains (the headless
+layer never writes `calc()`). The high-frequency properties are registered as non-inheriting
+via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` is unavailable):
+
+| Property | Written by | Meaning |
+| ------------------------------- | ------------- | ------------------------------------------------ |
+| `--cl-drawer-swipe-movement-y` | drag engine | px live drag delta on the Y axis (0 at rest) |
+| `--cl-drawer-swipe-progress` | drag engine | 0..1 dismiss progress (drives backdrop fade) |
+| `--cl-drawer-snap-point-offset` | snap layer | px resting translateY of the active snap point |
+| `--cl-drawer-swipe-strength` | drag engine | 0.1..1 from release velocity (scales exit speed) |
+| `--cl-drawer-nested-drawers` | nesting layer | count of open nested children |
+
+The backdrop, viewport, and popup stay mounted through the exit animation, so enter/exit
+transitions are CSS-driven. Sum the resting snap offset with the live drag delta so the sheet
+follows the finger, and drop the transition while `data-cl-swiping` is present:
+
+```css
+.drawer-popup {
+ transform: translateY(calc(var(--cl-drawer-snap-point-offset, 0px) + var(--cl-drawer-swipe-movement-y, 0px)));
+ transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1);
+}
+.drawer-popup[data-cl-starting-style],
+.drawer-popup[data-cl-ending-style] {
+ transform: translateY(100%);
+}
+.drawer-popup[data-cl-swiping] {
+ transition-duration: 0ms;
+}
+.drawer-backdrop {
+ opacity: calc(0.2 * (1 - var(--cl-drawer-swipe-progress, 0)));
+}
+```
diff --git a/packages/swingset/src/stories/drawer.stories.tsx b/packages/swingset/src/stories/drawer.stories.tsx
new file mode 100644
index 00000000000..cd32ebc8486
--- /dev/null
+++ b/packages/swingset/src/stories/drawer.stories.tsx
@@ -0,0 +1,37 @@
+import { Drawer } from '@clerk/headless/drawer';
+
+import type { StoryMeta } from '@/lib/types';
+
+// Headless primitives ship no styles. This single demo renders the primitive raw —
+// unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state,
+// the drag-to-dismiss gesture, and ARIA wiring via the `data-cl-*` attributes each part
+// emits, with zero appearance. It is embedded once into the overview via `` in the
+// MDX (the one thing prose can't convey: that it opens, traps focus, and dismisses on drag
+// / Escape / outside press). There is no interactive knob canvas for headless primitives.
+
+export const meta: StoryMeta = {
+ group: 'Primitives',
+ title: 'Drawer',
+ source: 'packages/headless/src/primitives/drawer/index.ts',
+};
+
+export function Default() {
+ return (
+
+ Open drawer
+
+
+
+
+
+ Sheet title
+
+ This is an unstyled bottom sheet. Drag it down, press Escape, or click outside to dismiss.
+
+ Close
+
+
+
+
+ );
+}