Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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.
},
Comment thread
alexcarpenter marked this conversation as resolved.
"./hooks": {
"import": "./dist/hooks/index.js",
"types": "./dist/hooks/index.d.ts"
Expand Down
218 changes: 218 additions & 0 deletions packages/headless/src/primitives/drawer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# 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.

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

## 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`); the parent receives
`data-cl-nested-drawer-open` and `--cl-drawer-nested-drawers` so the styled layer can scale it back.
While a nested child is dragged, the parent also receives `data-cl-nested-drawer-swiping` and a live
`--cl-drawer-nested-drag-progress` (0 = child at rest, 1 = child fully dismissed), so the styled layer
can interpolate the parent's scale back in as the child is dragged down (vaul-style coupling).

## 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.
- `--cl-drawer-frontmost-height` peek math (the live scale-back interpolation via
`--cl-drawer-nested-drag-progress` is built; frontmost-height-based peek offsets are not).
- 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;
69 changes: 69 additions & 0 deletions packages/headless/src/primitives/drawer/css-vars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* 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',
/** px frontmost open child height (for the parent's nested-stack math). */
frontmostHeight: '--cl-drawer-frontmost-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 */
}
}
}
43 changes: 43 additions & 0 deletions packages/headless/src/primitives/drawer/drawer-backdrop.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, DrawerBackdropProps>(
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<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
swiping: (v: boolean): Record<string, string> | null => (v ? { [DrawerAttrs.swiping]: '' } : null),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
Loading
Loading