Skip to content

feat(headless): add headless Drawer primitive#9056

Open
alexcarpenter wants to merge 8 commits into
mainfrom
feat/headless-drawer
Open

feat(headless): add headless Drawer primitive#9056
alexcarpenter wants to merge 8 commits into
mainfrom
feat/headless-drawer

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented Jul 1, 2026

Copy link
Copy Markdown
Member

Description

Adds a headless Drawer primitive to @clerk/headless (@clerk/headless/drawer): a modal bottom-sheet overlay with drag-to-dismiss, optional snap points, virtual-keyboard awareness, nested drawers, and detached triggers. It reuses 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.

Docs: https://swingset-git-feat-headless-drawer.clerkstage.dev/primitives/drawer

Summary by CodeRabbit

  • New Features
    • Added the headless Drawer primitive suite (Root/Trigger/Portal/Backdrop/Viewport/Popup/Handle/Title/Description/Close) with drag-to-dismiss, snap points, nested drawers, and detached trigger control.
    • Exposed a new ./drawer public entry point, plus a styling contract via registered CSS variables and data-cl-* state attributes.
  • Documentation
    • Added a comprehensive Drawer README and Storybook docs/stories, including updated primitives docs registry support.
  • Tests
    • Added extensive interaction, accessibility, snap/virtual-keyboard repositioning, nesting, and both client + SSR coverage (including regression cases).

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-bot

changeset-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e985376

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jul 1, 2026 12:16pm
swingset Ready Ready Preview, Comment Jul 1, 2026 12:16pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a new headless Drawer primitive with drag, snap, nesting, input repositioning, subcomponents, exports, docs, tests, and build/docs registry wiring.

Changes

Drawer Primitive

Layer / File(s) Summary
Contracts and styling hooks
packages/headless/src/primitives/drawer/constants.ts, css-vars.ts, drawer-context.ts, drawer-handle.ts
Defines tuning constants, CSS/data-attribute contracts, context types/hooks, and the imperative handle API.
Drag, snap, and viewport behavior
helpers.ts, use-snap-points.ts, use-drawer-drag.ts, use-reposition-inputs.ts
Adds gesture helpers, snap-point release logic, pointer drag handling, and visual-viewport input repositioning.
Drawer root orchestration
drawer-root.tsx
Wires open state, Floating UI, nested drawer coordination, and the DrawerContext value.
Drawer subcomponents
drawer-trigger.tsx, drawer-popup.tsx, drawer-portal.tsx, drawer-viewport.tsx, drawer-backdrop.tsx, drawer-title.tsx, drawer-description.tsx, drawer-close.tsx, drawer-handle-grip.tsx
Implements the compound Drawer parts that consume context and map state to DOM attributes.
Exports and build wiring
parts.ts, index.ts, package.json, vite.config.ts, .changeset/*
Exposes the public Drawer surface and adds package/build entry wiring.
Docs and registry wiring
README.md, packages/swingset/src/stories/drawer.*, packages/swingset/src/components/DocsViewer.tsx, packages/swingset/src/lib/registry.ts
Documents usage and styling, and registers the story/docs entry.
Behavior and SSR tests
drawer.test.tsx, drawer.ssr.test.tsx
Covers interaction behavior, nesting, snap points, keyboard repositioning, and server rendering.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

A rabbit built a drawer with hops and gentle sway,
It snaps and nests and listens while the keyboards play.
No styles in the headless burrow, just attributes bright,
I twitch my nose and beam at this tidy drawer delight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a new headless Drawer primitive to @clerk/headless.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9056

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9056

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9056

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9056

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9056

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9056

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9056

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9056

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9056

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9056

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9056

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9056

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9056

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9056

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9056

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9056

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9056

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9056

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9056

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9056

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9056

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9056

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9056

commit: e985376

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
packages/headless/src/primitives/drawer/drawer-handle.ts (1)

3-17: 📐 Maintainability & Code Quality | 🔵 Trivial

Please route this new handle API through a Docs review.

DrawerHandle / createDrawerHandle() look reference-facing, and this JSDoc will likely flow into generated /object/** docs. A Docs-team pass here would help confirm the detached-trigger contract is documented the way we want before release. As per path instructions, "Clerk Docs now generates /object/** reference documentation from JSDoc comments in the clerk/javascript source code" and "If JSDoc changes may affect generated Clerk Docs content, leave a review note reminding the contributor that the Docs team may need to review the change."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/drawer/drawer-handle.ts` around lines 3 -
17, This new reference-facing DrawerHandle/createDrawerHandle API and its JSDoc
should be routed through Docs review because it will surface in generated
/object/** documentation. Add a review note or equivalent reminder near the
DrawerHandle interface and createDrawerHandle() implementation indicating that
the Docs team should confirm the detached-trigger contract wording before
release.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/wise-drawers-appear.md:
- Around line 1-2: The changeset is empty even though this PR adds the new
public primitive and export subpath for `@clerk/headless/drawer`, so it will not
generate a release entry. Update the changeset to include a minor version bump
for `@clerk/headless` and add a short summary describing the new drawer
primitive/export, or confirm that release tracking is being handled separately
before leaving it empty.

In `@packages/headless/package.json`:
- Around line 44-47: The conditional export entry for the drawer subpath in
package.json has "import" before "types", which should be reordered to put
"types" first. Update the entire exports map in the package.json exports object
so every conditional subpath follows the same order, using the drawer entry as
the pattern to fix and keeping the rest of the export targets unchanged.

In `@packages/headless/src/primitives/drawer/drawer-popup.tsx`:
- Around line 65-76: The popup’s `ownProps` in `drawer-popup.tsx` applies a
blanket `touchAction: 'none'`, which blocks native scrolling inside the entire
drawer. Update `DrawerPopup` so `touch-action` is only applied to the actual
drag handle or toggled during an active drag, and keep the rest of the popup
scrollable on touch devices.

In `@packages/headless/src/primitives/drawer/drawer-root.tsx`:
- Around line 87-109: The Drawer.Root state logic is mixing two sources of
truth: when a `handle` is present, `handleOpen` overrides `internalOpen`, so
`props.open`/`props.defaultOpen` no longer control the rendered state and
`onOpenChange` can be skipped. Update `DrawerRoot` to either keep `handle`
synchronized with the controllable state in `useControllableState`/`setOpen`,
including propagating changes through `onOpenChange`, or explicitly disallow
passing both `handle` and `open`/`defaultOpen` together. Use the `handleOpen`
and `setOpen` paths in `drawer-root.tsx` as the place to reconcile the contract.

In `@packages/headless/src/primitives/drawer/drawer.test.tsx`:
- Line 31: The type annotations in DrawerFixture, ControlsFixture, and
AncestorScrollFixture currently reference React.ComponentProps without importing
React, so the test file will not type-check. Update these signatures to use
ComponentProps imported from react, or alternatively add the missing React
import, and make sure all three fixture definitions are adjusted consistently.

In `@packages/headless/src/primitives/drawer/helpers.ts`:
- Around line 76-83: Guard the pointer-capture call in safeCapture before
invoking el[method](id), because setPointerCapture/releasePointerCapture may be
missing and cause a TypeError that bypasses the current NotFoundError handling.
Update safeCapture in helpers.ts to check that the selected method exists on the
Element (or otherwise safely no-op) before calling it, while keeping the
existing DOMException NotFoundError suppression for supported browsers.

In `@packages/headless/src/primitives/drawer/use-drawer-drag.ts`:
- Around line 249-254: The release path in use-drawer-drag is discarding
velocity direction by converting the sampled value with Math.abs, which causes
snap and dismiss logic to treat upward and downward flicks the same. Keep the
velocity signed when computing the release value and pass that signed velocity
through snap.onRelease and the downstream logic around the release checks so
direction-sensitive behavior stays correct. Update the SnapReleaseArgs contract
in use-snap-points to document the signed velocity consistently and ensure any
release handling that compares dist and v uses the signed value rather than only
magnitude.

In `@packages/headless/src/primitives/drawer/use-snap-points.ts`:
- Around line 55-64: Normalize snap-point bounds in useSnapPoints: treat an
empty snapPoints array as having no valid indices, and clamp both
opts.defaultActiveSnapPoint and opts.activeSnapPoint into the valid 0..lastIndex
range before passing them to useControllableState. Update the
lastIndex/defaultIndex logic so [] does not produce -1, and ensure restOffset
and snapTo always receive a safe index/offset. Apply the same sanitization in
the related snap-point handling around snapTo/restOffset so external values
cannot produce NaNpx.

---

Nitpick comments:
In `@packages/headless/src/primitives/drawer/drawer-handle.ts`:
- Around line 3-17: This new reference-facing DrawerHandle/createDrawerHandle
API and its JSDoc should be routed through Docs review because it will surface
in generated /object/** documentation. Add a review note or equivalent reminder
near the DrawerHandle interface and createDrawerHandle() implementation
indicating that the Docs team should confirm the detached-trigger contract
wording before release.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: d9c9de39-a770-45d5-9170-df08a70ca8a5

📥 Commits

Reviewing files that changed from the base of the PR and between 08737e5 and 5da5e3d.

📒 Files selected for processing (29)
  • .changeset/wise-drawers-appear.md
  • packages/headless/package.json
  • packages/headless/src/primitives/drawer/README.md
  • packages/headless/src/primitives/drawer/constants.ts
  • packages/headless/src/primitives/drawer/css-vars.ts
  • packages/headless/src/primitives/drawer/drawer-backdrop.tsx
  • packages/headless/src/primitives/drawer/drawer-close.tsx
  • packages/headless/src/primitives/drawer/drawer-context.ts
  • packages/headless/src/primitives/drawer/drawer-description.tsx
  • packages/headless/src/primitives/drawer/drawer-handle-grip.tsx
  • packages/headless/src/primitives/drawer/drawer-handle.ts
  • packages/headless/src/primitives/drawer/drawer-popup.tsx
  • packages/headless/src/primitives/drawer/drawer-portal.tsx
  • packages/headless/src/primitives/drawer/drawer-root.tsx
  • packages/headless/src/primitives/drawer/drawer-title.tsx
  • packages/headless/src/primitives/drawer/drawer-trigger.tsx
  • packages/headless/src/primitives/drawer/drawer-viewport.tsx
  • packages/headless/src/primitives/drawer/drawer.test.tsx
  • packages/headless/src/primitives/drawer/helpers.ts
  • packages/headless/src/primitives/drawer/index.ts
  • packages/headless/src/primitives/drawer/parts.ts
  • packages/headless/src/primitives/drawer/use-drawer-drag.ts
  • packages/headless/src/primitives/drawer/use-reposition-inputs.ts
  • packages/headless/src/primitives/drawer/use-snap-points.ts
  • packages/headless/vite.config.ts
  • packages/swingset/src/components/DocsViewer.tsx
  • packages/swingset/src/lib/registry.ts
  • packages/swingset/src/stories/drawer.mdx
  • packages/swingset/src/stories/drawer.stories.tsx

Comment thread .changeset/wise-drawers-appear.md
Comment thread packages/headless/package.json
Comment thread packages/headless/src/primitives/drawer/drawer-popup.tsx
Comment thread packages/headless/src/primitives/drawer/drawer-root.tsx Outdated
Comment thread packages/headless/src/primitives/drawer/drawer.test.tsx
Comment thread packages/headless/src/primitives/drawer/helpers.ts
Comment thread packages/headless/src/primitives/drawer/use-drawer-drag.ts Outdated
Comment thread packages/headless/src/primitives/drawer/use-reposition-inputs.ts
Comment thread packages/headless/src/primitives/drawer/use-snap-points.ts
useSnapPoints computed the resting offset from window.innerHeight during render, crashing server renders of a Drawer with snapPoints (ReferenceError: window is not defined). Guard the read so it returns 0 until the client can measure; the popup mount-effect writes the real offset. Adds a node-environment renderToString regression test.
…signals

Settle the parent's live scale toward the rest it is heading to on release
(scaled-back if the child stays open, full on dismiss), matching the open-count
drop so the styled scale animates one direction with no backward flicker.
Restores the childOpen signal on onNestedRelease; snap release now returns it.

Reset --cl-drawer-nested-drag-progress to 0 when a nested child opens so a prior
dismiss does not leave the next parent un-scaled. Drop the unused
--cl-drawer-frontmost-height var (vaul uses a fixed displacement, not a
height-based peek) and document the full nested styled-layer recipe.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/headless/src/primitives/drawer/drawer.ssr.test.tsx (1)

15-27: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider asserting on rendered output, not just "no throw".

Both tests only check .not.toThrow(). Capturing the returned HTML string and asserting it contains the expected content (e.g., "hi") would make the tests stronger regression guards, catching cases where SSR silently swallows an error or renders empty markup.

♻️ Optional strengthening
-  it('renders a plain drawer on the server', () => {
-    expect(() =>
-      renderToString(
+  it('renders a plain drawer on the server', () => {
+    let html = '';
+    expect(() => {
+      html = renderToString(
         <Drawer.Root open>
           <Drawer.Portal>
             <Drawer.Viewport>
               <Drawer.Popup>hi</Drawer.Popup>
             </Drawer.Viewport>
           </Drawer.Portal>
         </Drawer.Root>,
-      ),
-    ).not.toThrow();
+      );
+    }).not.toThrow();
+    expect(html).toContain('hi');
   });

Also applies to: 31-46

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/drawer/drawer.ssr.test.tsx` around lines 15
- 27, The SSR tests in Drawer should do more than assert renderToString does not
throw; update the relevant test cases in
Drawer.Root/Drawer.Portal/Drawer.Viewport/Drawer.Popup to capture the returned
HTML string and assert it includes the expected rendered content such as “hi”.
Keep the existing no-throw check if desired, but strengthen the test by
verifying output from renderToString so the assertions fail when markup is
missing or empty.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/headless/src/primitives/drawer/drawer.ssr.test.tsx`:
- Around line 15-27: The SSR tests in Drawer should do more than assert
renderToString does not throw; update the relevant test cases in
Drawer.Root/Drawer.Portal/Drawer.Viewport/Drawer.Popup to capture the returned
HTML string and assert it includes the expected rendered content such as “hi”.
Keep the existing no-throw check if desired, but strengthen the test by
verifying output from renderToString so the assertions fail when markup is
missing or empty.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a2445bb-0d1b-4b4b-8d71-9793d06c84ee

📥 Commits

Reviewing files that changed from the base of the PR and between 5b08cdc and b2d5e9e.

📒 Files selected for processing (2)
  • packages/headless/src/primitives/drawer/drawer.ssr.test.tsx
  • packages/headless/src/primitives/drawer/use-snap-points.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/headless/src/primitives/drawer/use-snap-points.ts

Address CodeRabbit review on PR #9056:

- use-drawer-drag/use-snap-points: keep release velocity signed so a fast
  upward flick that ends net-downward settles upward (or expands a snap
  point) instead of being read as a downward dismiss.
- use-snap-points: treat an empty `snapPoints` array as "no snap points"
  and clamp externally supplied indices, preventing `-1`/`NaNpx` offsets.
- drawer-popup: don't surface snap state for an empty `snapPoints` array.
- helpers: guard `safeCapture` against environments missing
  set/releasePointerCapture (an absent method threw TypeError past the
  NotFoundError catch).
- use-reposition-inputs: clear the popup lift when the keyboard closes so
  the sheet doesn't stay raised until unmount.
…ched handle

Following Base UI's dialog-handle model: the component's controllable open
state is the one source of truth, and every transition — including imperative
handle calls — flows through a single `setOpen` that fires `onOpenChange`.

Previously, passing a `handle` made the handle store the source of truth, so
`open`/`defaultOpen` were ignored and handle-driven transitions (detached
trigger clicks, `handle.open()`) bypassed `onOpenChange`.

Now `Drawer.Root` owns open state via `useControllableState`, and the handle is
a bridge: `connect()` routes its imperative calls back through the root's
`setOpen`, `isOpen` reflects the root, and the root `emit()`s on change so
detached triggers re-read. An open requested before the root mounts is adopted
on connect without clobbering `defaultOpen`.

Fixes the CodeRabbit contract finding on PR #9056.
…touchend leak

Reposition: measure the popup's natural height by clearing our own cap before
reading getBoundingClientRect, so the height cap is idempotent. Previously it
read back the value it had just set, so the cap flipped off on the next
visualViewport resize and back on the one after, thrashing the sheet height
while the keyboard stayed open.

Drag: track the iOS touchend fallback listener and remove it on release and on
unmount (and drop a stale one before adding a new one), so it can't outlive its
gesture or accumulate on window across gestures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant