-
Notifications
You must be signed in to change notification settings - Fork 462
feat(headless): add headless Drawer primitive #9056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
alexcarpenter
wants to merge
8
commits into
main
Choose a base branch
from
feat/headless-drawer
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1e86f2e
feat(headless): add headless Drawer primitive
alexcarpenter 5da5e3d
add swingset story
alexcarpenter 5b08cdc
feat(headless): live nested-drawer scale-in coupling on drag
alexcarpenter b2d5e9e
fix(headless): guard Drawer snap-point offset against SSR
alexcarpenter ee2f4b1
fix(headless): flicker-free nested-drawer release + complete scaling …
alexcarpenter 5701054
fix(headless): harden Drawer drag, snap points, and keyboard reposition
alexcarpenter 2eb1809
fix(headless): make Drawer.Root the single source of truth for a deta…
alexcarpenter e985376
fix(headless): stop Drawer keyboard-reposition height thrash and iOS …
alexcarpenter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 */ | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.