Skip to content

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

Open
alexcarpenter wants to merge 3 commits intomainfrom
carp/headless-foundation
Open

feat(headless): add package foundation with Dialog primitive#8474
alexcarpenter wants to merge 3 commits intomainfrom
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 May 5, 2026 6:37pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: 26a1fec

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/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@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: 26a1fec

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

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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
@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."

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>
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
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>
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 on lines +258 to +286
function DialogPopup(props: DialogPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, transitionProps } =
useDialogContext();

const combinedRef = useMergeRefs([popupRef, refs.setFloating]);

const defaultProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
...(getFloatingProps() as React.ComponentPropsWithRef<'div'>),
...transitionProps,
};

return (
<FloatingFocusManager
context={floatingContext}
modal={modal}
>
{renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
})}
</FloatingFocusManager>
);
}
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Dialog popup renders even when closed if Dialog.Portal is omitted.

DialogPopup is not gated by mounted, so it still mounts FloatingFocusManager and popup markup in closed state unless consumers always wrap it with Dialog.Portal. That can leak closed content and affect focus behavior.

🐛 Proposed fix
 function DialogPopup(props: DialogPopupProps) {
   const { render, ...otherProps } = props;
-  const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, transitionProps } =
+  const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, transitionProps, mounted } =
     useDialogContext();

+  if (!mounted) {
+    return null;
+  }
+
   const combinedRef = useMergeRefs([popupRef, refs.setFloating]);

   const defaultProps = {

As per coding guidelines, "Implement proper focus management for keyboard navigation in React components" and "Restrict feedback to errors, security risks, or functionality-breaking problems."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function DialogPopup(props: DialogPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, transitionProps } =
useDialogContext();
const combinedRef = useMergeRefs([popupRef, refs.setFloating]);
const defaultProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
...(getFloatingProps() as React.ComponentPropsWithRef<'div'>),
...transitionProps,
};
return (
<FloatingFocusManager
context={floatingContext}
modal={modal}
>
{renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
})}
</FloatingFocusManager>
);
}
function DialogPopup(props: DialogPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, transitionProps, mounted } =
useDialogContext();
if (!mounted) {
return null;
}
const combinedRef = useMergeRefs([popupRef, refs.setFloating]);
const defaultProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
...(getFloatingProps() as React.ComponentPropsWithRef<'div'>),
...transitionProps,
};
return (
<FloatingFocusManager
context={floatingContext}
modal={modal}
>
{renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
})}
</FloatingFocusManager>
);
}
🤖 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/dialog/dialog.tsx` around lines 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.

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