From 1e86f2e4801269b087bb4482981f7fbd0f0c5826 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 30 Jun 2026 20:30:01 -0400 Subject: [PATCH 1/8] feat(headless): add headless Drawer primitive A modal bottom-sheet overlay (`@clerk/headless/drawer`) built on the same Floating UI infrastructure as Dialog, with a hand-rolled pointer/transform drag engine: - Drag-to-dismiss with velocity + distance thresholds - Optional snap points (square-root overshoot damping; resets to the default on close) - Virtual-keyboard awareness, nested drawers, detached triggers - A scroll-aware drag gate that never hijacks form controls (select, range, slider thumbs, [data-cl-drawer-no-drag], and text/input/textarea/contenteditable selections) or scrolled inner content - Ships zero CSS: emits raw --cl-drawer-* custom properties and data-cl-* attributes 59 tests (drag, gate branches, snap lifecycle, nested counting, a11y). --- .changeset/wise-drawers-appear.md | 2 + packages/headless/package.json | 4 + .../headless/src/primitives/drawer/README.md | 212 +++ .../src/primitives/drawer/constants.ts | 27 + .../src/primitives/drawer/css-vars.ts | 66 + .../src/primitives/drawer/drawer-backdrop.tsx | 43 + .../src/primitives/drawer/drawer-close.tsx | 29 + .../src/primitives/drawer/drawer-context.ts | 60 + .../primitives/drawer/drawer-description.tsx | 28 + .../primitives/drawer/drawer-handle-grip.tsx | 33 + .../src/primitives/drawer/drawer-handle.ts | 45 + .../src/primitives/drawer/drawer-popup.tsx | 111 ++ .../src/primitives/drawer/drawer-portal.tsx | 21 + .../src/primitives/drawer/drawer-root.tsx | 269 ++++ .../src/primitives/drawer/drawer-title.tsx | 26 + .../src/primitives/drawer/drawer-trigger.tsx | 68 + .../src/primitives/drawer/drawer-viewport.tsx | 56 + .../src/primitives/drawer/drawer.test.tsx | 1244 +++++++++++++++++ .../headless/src/primitives/drawer/helpers.ts | 84 ++ .../headless/src/primitives/drawer/index.ts | 22 + .../headless/src/primitives/drawer/parts.ts | 10 + .../src/primitives/drawer/use-drawer-drag.ts | 279 ++++ .../drawer/use-reposition-inputs.ts | 61 + .../src/primitives/drawer/use-snap-points.ts | 157 +++ packages/headless/vite.config.ts | 1 + 25 files changed, 2958 insertions(+) create mode 100644 .changeset/wise-drawers-appear.md create mode 100644 packages/headless/src/primitives/drawer/README.md create mode 100644 packages/headless/src/primitives/drawer/constants.ts create mode 100644 packages/headless/src/primitives/drawer/css-vars.ts create mode 100644 packages/headless/src/primitives/drawer/drawer-backdrop.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-close.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-context.ts create mode 100644 packages/headless/src/primitives/drawer/drawer-description.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-handle-grip.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-handle.ts create mode 100644 packages/headless/src/primitives/drawer/drawer-popup.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-portal.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-root.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-title.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-trigger.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer-viewport.tsx create mode 100644 packages/headless/src/primitives/drawer/drawer.test.tsx create mode 100644 packages/headless/src/primitives/drawer/helpers.ts create mode 100644 packages/headless/src/primitives/drawer/index.ts create mode 100644 packages/headless/src/primitives/drawer/parts.ts create mode 100644 packages/headless/src/primitives/drawer/use-drawer-drag.ts create mode 100644 packages/headless/src/primitives/drawer/use-reposition-inputs.ts create mode 100644 packages/headless/src/primitives/drawer/use-snap-points.ts diff --git a/.changeset/wise-drawers-appear.md b/.changeset/wise-drawers-appear.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wise-drawers-appear.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/package.json b/packages/headless/package.json index 495046e13a7..fc489fe367a 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -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" + }, "./hooks": { "import": "./dist/hooks/index.js", "types": "./dist/hooks/index.d.ts" diff --git a/packages/headless/src/primitives/drawer/README.md b/packages/headless/src/primitives/drawer/README.md new file mode 100644 index 00000000000..18e6b8ba93b --- /dev/null +++ b/packages/headless/src/primitives/drawer/README.md @@ -0,0 +1,212 @@ +# 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'; + + + Open + + + + + + Sheet title + Optional description. +

Sheet content.

+ Close +
+
+
+
; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + {/* ... */} +; +``` + +### Detached trigger + +A trigger rendered **outside** `Drawer.Root` can drive it through a shared handle: + +```tsx +const handle = createDrawerHandle(); + +<> + Open from anywhere + {/* ... */} +; +``` + +`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. + + {/* ... */} + +``` + +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. +{/* ... */} +``` + +## Parts + +| Part | Default Element | Description | +| -------------------- | --------------- | ---------------------------------------------------------------- | +| `Drawer.Root` | — | Root context provider; owns open/drag/snap/nesting state | +| `Drawer.Trigger` | `