Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-drawers-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
},
"./drawer": {
"import": "./dist/primitives/drawer/index.js",
"types": "./dist/primitives/drawer/index.d.ts"
},
Comment thread
alexcarpenter marked this conversation as resolved.
"./hooks": {
"import": "./dist/hooks/index.js",
"types": "./dist/hooks/index.d.ts"
Expand Down
256 changes: 256 additions & 0 deletions packages/headless/src/primitives/drawer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Drawer

A modal bottom-sheet overlay with drag-to-dismiss, optional snap points, virtual-keyboard
awareness, and nesting. Built on the same Floating UI infrastructure as `Dialog` (portal, focus
trap, scroll lock, dismiss, `FloatingTree` nesting, enter/exit transitions) with a hand-rolled
pointer/transform drag engine layered on top.

The headless layer emits **raw** CSS custom properties and `data-cl-*` attributes and ships **zero
CSS**. The styled (mosaic) layer composes the actual `transform` / `opacity` / `calc()` chains from
those inputs.

## When to Use

- A sheet that slides up from the bottom edge and is dismissed by swiping down.
- Mobile-first flows where snap points (peek / half / full) make sense.
- Prefer `Dialog` for centered modals that are not anchored to the bottom and have no drag gesture.

## Usage

```tsx
import { Drawer } from '@clerk/headless/drawer';

<Drawer.Root>
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Backdrop />
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Handle />
<Drawer.Title>Sheet title</Drawer.Title>
<Drawer.Description>Optional description.</Drawer.Description>
<p>Sheet content.</p>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Drawer.Root
open={open}
onOpenChange={setOpen}
>
{/* ... */}
</Drawer.Root>;
```

### Detached trigger

A trigger rendered **outside** `Drawer.Root` can drive it through a shared handle:

```tsx
const handle = createDrawerHandle();

<>
<Drawer.Trigger handle={handle}>Open from anywhere</Drawer.Trigger>
<Drawer.Root handle={handle}>{/* ... */}</Drawer.Root>
</>;
```

`handle.open()` / `handle.close()` / `handle.toggle()` work imperatively; `handle.isOpen` and
`handle.subscribe(cb)` make it `useSyncExternalStore`-compatible.

`Drawer.Root` stays the single source of truth, so the handle composes with `open` / `defaultOpen`
/ `onOpenChange`: `defaultOpen` and a controlled `open` prop still apply, and every handle-driven
transition fires `onOpenChange` just like a click or dismissal.

### Snap points

```tsx
// Ascending fractions of the viewport the sheet can rest at; 1 = full height.
<Drawer.Root
snapPoints={[0.4, 0.75, 1]}
defaultActiveSnapPoint={0}
>
{/* ... */}
</Drawer.Root>
```

Control the active point with `activeSnapPoint` / `onActiveSnapPointChange`. 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`). An uncontrolled drawer returns to its default snap point when it
closes, so the next open starts fresh. Dragging past the fully-open edge is square-root damped so the
sheet resists overshooting.

### Handle-only dragging

```tsx
// Only a press starting on Drawer.Handle initiates the drag; the body scrolls normally.
<Drawer.Root handleOnly>{/* ... */}</Drawer.Root>
```

## Parts

| Part | Default Element | Description |
| -------------------- | --------------- | ---------------------------------------------------------------- |
| `Drawer.Root` | — | Root context provider; owns open/drag/snap/nesting state |
| `Drawer.Trigger` | `<button>` | Opens the drawer (in-tree, or via a detached `handle`) |
| `Drawer.Portal` | — | Portals children (defaults to `document.body`) |
| `Drawer.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Drawer.Viewport` | `<div>` | Fixed full-viewport container; owns scroll lock |
| `Drawer.Popup` | `<div>` | The sheet (`role="dialog"`); hosts the drag gesture + focus trap |
| `Drawer.Handle` | `<div>` | Visual drag grip; the hit-test target when `handleOnly` |
| `Drawer.Title` | `<h2>` | Heading, wired to `aria-labelledby` |
| `Drawer.Description` | `<p>` | Description, wired to `aria-describedby` |
| `Drawer.Close` | `<button>` | 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 `<select>`, 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<T>()` + 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).
27 changes: 27 additions & 0 deletions packages/headless/src/primitives/drawer/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
67 changes: 67 additions & 0 deletions packages/headless/src/primitives/drawer/css-vars.ts
Original file line number Diff line number Diff line change
@@ -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, '<length>', '0px'],
[DrawerCssVars.snapOffset, '<length>', '0px'],
[DrawerCssVars.swipeProgress, '<number>', '0'],
[DrawerCssVars.nestedDragProgress, '<number>', '0'],
] as const;
for (const [name, syntax, initialValue] of defs) {
try {
CSS.registerProperty({ name, syntax, inherits: false, initialValue });
} catch {
/* already registered */
}
}
}
Loading
Loading