Skip to content

feat(headless): add package foundation with Dialog primitive#8474

Open
alexcarpenter wants to merge 9 commits into
mainfrom
carp/headless-foundation
Open

feat(headless): add package foundation with Dialog primitive#8474
alexcarpenter wants to merge 9 commits into
mainfrom
carp/headless-foundation

Conversation

@alexcarpenter
Copy link
Copy Markdown
Member

@alexcarpenter alexcarpenter commented May 5, 2026

Summary

Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This is the first in a series of stacked PRs.

This PR establishes all core infrastructure and patterns:

  • Package scaffold: Vite lib build with per-primitive entry points, Vitest with happy-dom, TypeScript config
  • Core utils: renderElement (polymorphic rendering with render prop support, data-cl-* state attributes), mergeProps (event handler chaining, style/className merging)
  • Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition — the full controlled/uncontrolled + enter/exit animation lifecycle
  • First primitive: Dialog — modal with focus trapping, scroll lock, scoped portal support, compound component API

Review Stack

Review order — start at the bottom and work up. Each PR builds on the one below it.

# PR Primitive Base branch
8 #8481 Autocomplete + floating-tree integration ← menu
7 #8480 Menu ← select
6 #8479 Select ← popover
5 #8478 Popover ← tooltip
4 #8477 cssVars + Tooltip ← tabs
3 #8476 Tabs ← accordion
2 #8475 Accordion ← foundation
1 #8474 Foundation (infra + hooks + utils + Dialog) ← main

Test plan

  • pnpm build succeeds with clean types
  • pnpm test — 72 tests pass (hooks, utils, Dialog)
  • CI passes

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
clerk-js-sandbox Skipped Skipped Jun 3, 2026 5:23pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: c3d5f6b

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 5, 2026

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: c3d5f6b

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

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

Adds a new internal ESM package @clerk/headless containing unstyled, accessible React primitives built on Floating UI. Introduces hooks: useAnimationsFinished, useControllableState, useTransitionStatus, useTransition; utilities: mergeProps, renderElement; a compound Dialog primitive with Trigger, Portal, Backdrop, Popup, Title, Description, Close; test helpers (axe); comprehensive Vitest tests for hooks, utils, and Dialog; TypeScript, Vite, and Vitest configs; package export mappings for subpaths; and README/primitive docs describing data-attribute conventions and dev scripts.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.39% 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
Title check ✅ Passed The title accurately summarizes the main change: introducing the @clerk/headless package foundation with a Dialog primitive. It is concise and specific.
Description check ✅ Passed The description comprehensively explains the PR's purpose, structure, and scope. It covers package scaffold, core utilities, hooks, the Dialog primitive, and provides context on the stacked PR series.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@packages/headless/README.md`:
- Around line 9-18: The README lists per-primitive import paths (e.g.
`@clerk/headless/select`, `@clerk/headless/tooltip`, etc.) that are not actually
exported by the package; either update the README to only show the real
entrypoints (e.g. ./dialog and ./utils) or add matching exports to the package's
exports map so those import paths resolve; locate the package exports via the
package.json "exports" field and the README table rows (Accordion, Autocomplete,
Dialog, Menu, Popover, Select, Tabs, Tooltip) and remove or replace non-exported
entries or add corresponding "./<primitive>" exports to package.json before
merging.

In `@packages/headless/src/primitives/dialog/dialog.tsx`:
- Around line 239-246: DialogBackdrop renders FloatingOverlay regardless of
mounted which locks body scroll; change the return so FloatingOverlay is only
rendered when the backdrop is mounted/open: in the DialogBackdrop function
(symbols: DialogBackdrop, mounted, scoped, backdropElement, FloatingOverlay,
lockScroll) keep the early return for scoped, but for the non-scoped path return
mounted ? <FloatingOverlay
lockScroll={lockScroll}>{backdropElement}</FloatingOverlay> : null (or return
null/undefined) so FloatingOverlay is not mounted when the dialog is closed.
🪄 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), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 5f11690d-ec4b-498a-83ca-49824b481e40

📥 Commits

Reviewing files that changed from the base of the PR and between 0b588e7 and 3abe7b8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (23)
  • packages/headless/README.md
  • packages/headless/package.json
  • packages/headless/src/hooks/use-animations-finished.test.ts
  • packages/headless/src/hooks/use-animations-finished.ts
  • packages/headless/src/hooks/use-controllable-state.test.ts
  • packages/headless/src/hooks/use-controllable-state.ts
  • packages/headless/src/hooks/use-transition-status.test.ts
  • packages/headless/src/hooks/use-transition-status.ts
  • packages/headless/src/hooks/use-transition.test.ts
  • packages/headless/src/hooks/use-transition.ts
  • packages/headless/src/primitives/dialog/README.md
  • packages/headless/src/primitives/dialog/dialog.test.tsx
  • packages/headless/src/primitives/dialog/dialog.tsx
  • packages/headless/src/primitives/dialog/index.ts
  • packages/headless/src/test-utils/axe.ts
  • packages/headless/src/utils/index.ts
  • packages/headless/src/utils/render-element.test.tsx
  • packages/headless/src/utils/render-element.tsx
  • packages/headless/tsconfig.json
  • packages/headless/vite.config.ts
  • packages/headless/vitest.config.ts
  • packages/headless/vitest.setup.ts
  • pnpm-workspace.yaml

Comment thread packages/headless/README.md
Comment thread packages/headless/src/primitives/dialog/dialog.tsx Outdated
@alexcarpenter
Copy link
Copy Markdown
Member Author

📋 PR Stack Overview

This is a stacked PR series breaking the @clerk/headless package into individually reviewable pieces. Start reviewing here — each subsequent PR adds exactly one primitive.

Review order

# PR Adds Lines (src + test)
→ 1 #8474 Foundation: package scaffold, renderElement/mergeProps, all 4 hooks, Dialog ~1200 + 1050
2 #8475 Accordion ~350 + 420
3 #8476 Tabs ~450 + 460
4 #8477 cssVars middleware + Tooltip ~410 + 580
5 #8478 Popover ~420 + 260
6 #8479 Select ~625 + 700
7 #8480 Menu (submenus, safePolygon) ~530 + 670
8 #8481 Autocomplete + cross-primitive integration tests ~585 + 1390

Key decisions to validate in this PR

  1. renderElement + mergeProps — the rendering abstraction every primitive uses
  2. useTransition stack — the enter/exit animation lifecycle (state machine + Web Animations API hook)
  3. Compound component patternObject.assign(Dialog, { Trigger, Popup, ... })
  4. data-cl-* attributes — sole styling hook, no class names emitted
  5. happy-dom for tests (matches CI, no Playwright needed)

Once the patterns here look good, the remaining PRs are mechanical — just "one more primitive using the same patterns."

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@packages/headless/src/hooks/use-transition-status.ts`:
- Around line 3-49: The hook useTransitionStatus currently has no explicit
return type; add a stable return type annotation (either an exported alias like
export type UseTransitionStatusReturn = { mounted: boolean; transitionStatus:
TransitionStatus; setMounted: React.Dispatch<React.SetStateAction<boolean>> }
and then annotate function signature as export function
useTransitionStatus(open: boolean): UseTransitionStatusReturn, or inline the
same shape) and ensure the React types are available (import React or import
type { Dispatch, SetStateAction } from 'react' and use
Dispatch<SetStateAction<boolean>> for setMounted). This makes the public API of
useTransitionStatus explicit and prevents accidental signature changes.
🪄 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), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 0cbb7e7f-ed41-4bc1-970f-86d2eee9f8b9

📥 Commits

Reviewing files that changed from the base of the PR and between 2a5b8e2 and 16b5d39.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (23)
  • packages/headless/README.md
  • packages/headless/package.json
  • packages/headless/src/hooks/use-animations-finished.test.ts
  • packages/headless/src/hooks/use-animations-finished.ts
  • packages/headless/src/hooks/use-controllable-state.test.ts
  • packages/headless/src/hooks/use-controllable-state.ts
  • packages/headless/src/hooks/use-transition-status.test.ts
  • packages/headless/src/hooks/use-transition-status.ts
  • packages/headless/src/hooks/use-transition.test.ts
  • packages/headless/src/hooks/use-transition.ts
  • packages/headless/src/primitives/dialog/README.md
  • packages/headless/src/primitives/dialog/dialog.test.tsx
  • packages/headless/src/primitives/dialog/dialog.tsx
  • packages/headless/src/primitives/dialog/index.ts
  • packages/headless/src/test-utils/axe.ts
  • packages/headless/src/utils/index.ts
  • packages/headless/src/utils/render-element.test.tsx
  • packages/headless/src/utils/render-element.tsx
  • packages/headless/tsconfig.json
  • packages/headless/vite.config.ts
  • packages/headless/vitest.config.ts
  • packages/headless/vitest.setup.ts
  • pnpm-workspace.yaml
✅ Files skipped from review due to trivial changes (9)
  • packages/headless/vite.config.ts
  • packages/headless/vitest.config.ts
  • packages/headless/src/utils/index.ts
  • pnpm-workspace.yaml
  • packages/headless/vitest.setup.ts
  • packages/headless/src/primitives/dialog/index.ts
  • packages/headless/src/hooks/use-animations-finished.ts
  • packages/headless/src/primitives/dialog/README.md
  • packages/headless/src/hooks/use-controllable-state.test.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • packages/headless/package.json
  • packages/headless/src/hooks/use-transition.ts
  • packages/headless/src/hooks/use-transition-status.test.ts
  • packages/headless/README.md
  • packages/headless/src/utils/render-element.tsx
  • packages/headless/tsconfig.json
  • packages/headless/src/utils/render-element.test.tsx
  • packages/headless/src/hooks/use-controllable-state.ts
  • packages/headless/src/hooks/use-transition.test.ts
  • packages/headless/src/primitives/dialog/dialog.tsx

Comment thread packages/headless/src/hooks/use-transition-status.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@packages/headless/src/primitives/dialog/dialog.tsx`:
- Around line 258-286: DialogPopup currently always mounts FloatingFocusManager
and the popup DOM; gate rendering on the dialog's mounted state from
useDialogContext (e.g., check mounted) so that if mounted is false the component
returns null (or nothing) and does not render FloatingFocusManager or the popup
markup; keep the existing defaultProps/mergeProps logic and combinedRef, but
wrap the final return in an if (mounted) { ... } else return null to prevent
closed content and focus manager from mounting when Dialog.Portal is omitted.
🪄 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), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: bf051868-fec4-46ae-9967-711f8129468b

📥 Commits

Reviewing files that changed from the base of the PR and between 7dac4af and 26a1fec.

📒 Files selected for processing (1)
  • packages/headless/src/primitives/dialog/dialog.tsx

Comment thread packages/headless/src/primitives/dialog/dialog.tsx Outdated
Copy link
Copy Markdown
Member

@Ephem Ephem left a comment

Choose a reason for hiding this comment

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

This is very neat! Just dropping off some partway review comments, mostly me asking questions to try to wrap my head around stuff. Will continue reviewing tomorrow.

I did skim the animation parts as well, they are looking great!

Comment thread packages/headless/src/utils/render-element.tsx Outdated
Comment thread packages/headless/package.json

## Architecture

- **Compound components** via `Object.assign` — each primitive is a single export with dot-accessed parts (`Select.Trigger`, `Select.Popup`, etc.)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this approach entirely disables tree-shaking? Maybe not a huge deal but since we are setting this up from scratch and now is the best time to fix it if we want to, I wanted to raise it.

If we still want compound components, I think export * as Dialog from './dialog'; achieves mostly the same but would tree shake if we set sideEffects: false?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think to achieve full tree-shaking we'd need to move all subcomponents into their own files and export similar to base-ui is doing with parts exports. https://github.com/mui/base-ui/tree/master/packages/react/src/avatar

Since this is not a public library this seemed unnecessary. but happy to reconsider if you think we should. lmk what you think!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

At the time, I was thinking how a natural step would be: #8654

When we want to export subcomponents like that, maybe we do want the underlying primitives to treeshake as well?

I think to achieve full tree-shaking we'd need to move all subcomponents into their own files and export similar to base-ui is doing with parts exports

Yes, that's exactly what I was thinking. It's annoying though so there's a real tradeoff not being able to colocate.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

took the base ui approach here 54016b4


- **Compound components** via `Object.assign` — each primitive is a single export with dot-accessed parts (`Select.Trigger`, `Select.Popup`, etc.)
- **`renderElement`** — every part uses this instead of returning JSX directly, enabling consumer `render` prop overrides and automatic state-to-data-attribute mapping
- **`data-cl-*` attributes** — structural (`data-cl-slot`), state (`data-cl-open`, `data-cl-selected`, `data-cl-active`), and animation lifecycle (`data-cl-starting-style`, `data-cl-ending-style`)
Copy link
Copy Markdown
Member

@Ephem Ephem May 6, 2026

Choose a reason for hiding this comment

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

I love the data-cl-* attributes. ❤️

Did you consider also namespacing these per primitive? Maybe that's not necessary?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

what do you mean per primitive?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

data-cl-select-open etc to allow for more narrow targeting when writing CSS?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Trying to keep the list lean. My recommendation would be the compose the selectors together

[data-cl-slot="select-root"][data-cl-open] { ... }

Comment thread packages/headless/src/utils/render-element.tsx
Comment thread packages/headless/README.md

Headless UI primitives for Clerk's component library. These are unstyled, accessible React components built on [Floating UI](https://floating-ui.com/) that handle positioning, keyboard navigation, focus management, and ARIA attributes.

This package is **internal** (`private: true`) and consumed by `@clerk/ui`. It exists as a separate package because `@clerk/ui` uses `@emotion/react` as its JSX source, which conflicts with the standard `react-jsx` transform these primitives require.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this intended to be private forever? I'm guessing yes and that anything we want public we export through ui right?

I'm trying to think through the public parts, the css data-cl-* attributes is an obvious one, the rest is probably only exposed through other components right?

Are there any cases for our public components where you think C1s will pass through render props, or any other decisions here that are going to shape public facing APIs down the line?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

so the data attributes are to be used for c1s to be able to tie into and style with. the custom rendering is internal only atm. Possibly in the future that could be a mechanism we expose for custom rendering.

alexcarpenter and others added 8 commits June 3, 2026 12:13
Introduces the @clerk/headless package — a zero-style React component
library providing accessible headless UI primitives. This first PR
establishes the core infrastructure and patterns:

- Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config
- Core utils: renderElement (polymorphic rendering with render prop support),
  mergeProps (event handler chaining, style/className merging)
- Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition
- First primitive: Dialog (modal with focus trapping, scroll lock, portal support)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…losed

FloatingOverlay applies overflow:hidden to the body, which locks scroll.
Previously it was always rendered in the non-scoped path — now it's only
mounted when the backdrop is visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without Dialog.Portal wrapping it, DialogPopup rendered FloatingFocusManager
and popup DOM unconditionally. Gate on mounted state to match DialogOverlay
and DialogPortal behavior.
Split the monolithic dialog.tsx into individual files per subcomponent
and use `export * as Dialog` namespace re-export instead of Object.assign.
This enables bundlers to tree-shake unused Dialog parts at the module level.

API change: <Dialog> becomes <Dialog.Root>
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.

2 participants