Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 2466247

Browse filesBrowse files
docs(repo): add headless primitives reference to mosaic skill (#9239)
1 parent dadebc2 commit 2466247
Copy full SHA for 2466247

4 files changed

+254-4Lines changed: 254 additions & 4 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file
+2Lines changed: 2 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Collapse file

‎.claude/skills/mosaic/SKILL.md‎

Copy file name to clipboardExpand all lines: .claude/skills/mosaic/SKILL.md
+1Lines changed: 1 addition & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ this skill is the _how-to_.
4444

4545
| You are… | Read |
4646
| -------------------------------------------------------------------- | ------------------------------------------------------ |
47+
| Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` |
4748
| Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` |
4849
| Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` |
4950
| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` |
Collapse file
+247Lines changed: 247 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# Headless primitives
2+
3+
`@clerk/headless` (`packages/headless/`) is the unstyled, accessible primitive
4+
layer under Mosaic: Accordion, Autocomplete, Collapsible, Dialog, Drawer,
5+
FileUpload, Menu, OTP, Popover, Select, Tabs, Tooltip. Every part emits **zero
6+
styles** — positioning, keyboard nav, focus management, dismiss, and ARIA are
7+
delegated to `@floating-ui/react`; all appearance is applied externally via
8+
`data-*` selectors and consumer classNames.
9+
10+
The package is `private: true` and consumed by `@clerk/ui`. It's a separate
11+
package because `@clerk/ui` sets `jsxImportSource: '@emotion/react'`, which
12+
conflicts with the standard `react-jsx` transform these primitives need.
13+
14+
## Read this for the _what_
15+
16+
Per-primitive API docs (parts, props, keyboard, data attributes, ARIA) live
17+
**next to the code** and are the source of truth:
18+
19+
- **`packages/headless/src/primitives/<name>/README.md`** — one per primitive.
20+
- **`packages/headless/README.md`** — package overview, the primitive table, and
21+
the full **consuming-from-`@clerk/ui`** guide (the `makeCustomizable` wrapper,
22+
the TS2742 annotation requirement, pass-through parts, the `render` escape
23+
hatch).
24+
25+
This file is the _how-to_ for the shared conventions — what every primitive has
26+
in common, so you can author a new one or a new part without re-deriving the
27+
pattern.
28+
29+
## Consuming a primitive
30+
31+
Every primitive is a compound component exported as a namespace. Import from the
32+
subpath; render `Root` + parts:
33+
34+
```tsx
35+
import { Select } from '@clerk/headless/select';
36+
37+
<Select.Root>
38+
<Select.Trigger>
39+
<Select.Value placeholder='Choose…' />
40+
</Select.Trigger>
41+
<Select.Positioner>
42+
<Select.Popup>
43+
<Select.Option
44+
value='a'
45+
label='A'
46+
/>
47+
</Select.Popup>
48+
</Select.Positioner>
49+
</Select.Root>;
50+
```
51+
52+
- **Each element-rendering part accepts native props for its tag plus a `render`
53+
prop.** Pass-through parts (`Root`, `Portal`) render no element of their own
54+
and have their own APIs instead. Unused parts tree-shake out.
55+
- **Style by className/`data-*`**, never by a slot attribute — the primitives
56+
don't emit one.
57+
- **`render` is the override escape hatch.** It takes a function
58+
(`render={props => <X {...props} />}`) **or an element**
59+
(`render={<Link />}`) — the element is cloned with the part's computed props
60+
and refs merged in.
61+
- **From `@clerk/ui`**, wrap element-rendering parts with `makeCustomizable` to
62+
get the theme-aware `sx` prop; pass-through parts (`Root`, `Portal`) are used
63+
directly. See `packages/headless/README.md`.
64+
65+
## Authoring a part: the useRender contract
66+
67+
Every part that renders a DOM element calls **`useRender`** (from
68+
`../../utils`) instead of returning JSX. This is the single mechanism behind
69+
`render` overrides, state→`data-*` mapping, and ref merging. (It replaced the
70+
old `renderElement` helper — `renderElement` no longer exists.)
71+
72+
```tsx
73+
'use client';
74+
import React from 'react';
75+
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
76+
import { useSelectContext } from './select-context';
77+
78+
export type SelectTriggerProps = ComponentProps<'button'>;
79+
80+
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
81+
function SelectTrigger(props, ref) {
82+
const { render, ...otherProps } = props;
83+
const { open, refs, getReferenceProps } = useSelectContext();
84+
85+
const ownProps = { type: 'button' } satisfies DefaultProps<'button'>;
86+
const defaultProps = { ...ownProps, ...getReferenceProps() };
87+
88+
return useRender({
89+
defaultTagName: 'button',
90+
render,
91+
state: { open },
92+
stateAttributesMapping: {
93+
open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }),
94+
},
95+
ref: [refs.setReference, ref],
96+
props: mergeProps<'button'>(defaultProps, otherProps),
97+
});
98+
},
99+
);
100+
```
101+
102+
`useRender` params:
103+
104+
| Param | Purpose |
105+
| ---------------------------------- | ------------------------------------------------------------------------------------------- |
106+
| `defaultTagName` | Tag rendered when no `render` is given. |
107+
| `render` | Consumer override: a render function **or** a React element (cloned with merged props). |
108+
| `props` | Props to spread onto the element. Pass refs via `ref`, not here. |
109+
| `ref` | A ref or **array** of refs; merged internally (`useMergeRefs`). E.g. `[refs.setX, ref]`. |
110+
| `state` + `stateAttributesMapping` | Maps state values to `data-*` attrs (below). |
111+
| `enabled` | When `false`, returns `null`. Positioners pass `enabled: mounted` to gate the floating DOM. |
112+
113+
Rules that hold for **every** part:
114+
115+
- **`'use client';`** at the top of every component file.
116+
- **`React.forwardRef`** for any part that renders an element.
117+
- **`const { render, ...otherProps } = props;`** — pull `render` out, spread the rest.
118+
- **`ownProps satisfies DefaultProps<Tag>`** for authored defaults. `DefaultProps`
119+
is the tag's props widened to allow `data-*` keys — `satisfies` type-checks
120+
every key against the real element without an `as` cast.
121+
- **`mergeProps(internal, consumer)` — internal first, consumer second.** Event
122+
handlers chain (internal fires, then consumer), `style` shallow-merges,
123+
`className` concatenates, everything else the consumer overwrites. This lets
124+
consumers extend behavior without breaking ARIA/handlers the primitive owns.
125+
126+
### state → data-attribute mapping
127+
128+
`stateAttributesMapping` maps each `state` key to a function returning a
129+
`data-*` object or `null` (omit). Return the boolean-off branch as `null` for
130+
presence attrs, or a second attribute for on/off pairs:
131+
132+
```ts
133+
// presence: attr only when true
134+
selected: v => (v ? { 'data-selected': '' } : null),
135+
disabled: v => (v ? { 'data-disabled': '' } : null),
136+
// pair: data-open vs data-closed
137+
open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }),
138+
```
139+
140+
Consumers then style off `[data-selected]`, `[data-open]`, etc.
141+
142+
## File layout of a primitive
143+
144+
Every `primitives/<name>/` folder follows the same shape:
145+
146+
| File | Holds |
147+
| ------------------- | ---------------------------------------------------------------------------------------------- |
148+
| `<name>-root.tsx` | Context provider; owns floating/interaction/transition state. Often wraps `FloatingTree`. |
149+
| `<name>-<part>.tsx` | One file per part (`-trigger`, `-popup`, `-positioner`, …), each a `forwardRef` + `useRender`. |
150+
| `<name>-context.ts` | Context type + `createContext` + guard hook (below). |
151+
| `parts.ts` | Re-exports each part under its short alias. |
152+
| `index.ts` | Public entry: namespace + prop-type re-exports. |
153+
| `<name>.test.tsx` | Tests (real Chromium via vitest browser mode, not jsdom). |
154+
| `README.md` | The primitive's API docs. |
155+
156+
**Context + guard hook** — the pattern that makes "used outside Root" a clear error:
157+
158+
```ts
159+
export const SelectContext = createContext<SelectContextValue | null>(null);
160+
161+
export function useSelectContext() {
162+
const ctx = useContext(SelectContext);
163+
if (!ctx) throw new Error('Select compound components must be used within <Select.Root>');
164+
return ctx;
165+
}
166+
```
167+
168+
**`parts.ts`** — alias each part; this is what the namespace spreads:
169+
170+
```ts
171+
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
172+
export { type SelectOptionProps, SelectOption as Option } from './select-option';
173+
//
174+
```
175+
176+
**`index.ts`** — namespace + public prop types (the prop types must be
177+
re-exported here or `@clerk/ui`'s `.d.ts` rollup hits TS2742):
178+
179+
```ts
180+
export * as Select from './parts';
181+
export type { SelectProps, SelectTriggerProps, SelectOptionProps /**/ } from './parts';
182+
```
183+
184+
**`Portal`** parts take an optional `root` and gate on `mounted`:
185+
186+
```tsx
187+
export function SelectPortal(props: {
188+
children: ReactNode;
189+
root?: HTMLElement | RefObject<HTMLElement | null> | null;
190+
}) {
191+
const { mounted } = useSelectContext();
192+
if (!mounted) return null;
193+
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
194+
}
195+
```
196+
197+
## Animation lifecycle (`data-*` driven)
198+
199+
All enter/exit timing lives in **CSS**; the primitives only toggle `data-*`
200+
attributes and drive unmount off the Web Animations API. Root spreads
201+
`transitionProps` (from `useTransition`) onto the Popup; the lifecycle:
202+
203+
- **Open** → synchronously `mounted=true`, status `'starting'`: first committed
204+
frame carries `data-open` + `data-starting-style` + inline `transition: none`
205+
(snapshot frame). One rAF later `data-starting-style` clears → CSS transitions
206+
fire toward the resting style.
207+
- **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied.
208+
After all animations on the element finish (`useAnimationsFinished`),
209+
`mounted` flips false and the element unmounts.
210+
211+
Consumer CSS keys off these: `[data-starting-style] { opacity: 0 }`,
212+
`[data-open] { animation: … }`, `[data-ending-style] { opacity: 0 }`.
213+
214+
**Positioners** gate the floating layer on `mounted` via `useRender`'s
215+
`enabled`, so the positioned DOM doesn't exist until the first frame:
216+
217+
```tsx
218+
const element = useRender({ defaultTagName: 'div', render, enabled: mounted, ref: [refs.setFloating, ref], props });
219+
if (!element) return null;
220+
```
221+
222+
## Shared hooks (`@clerk/headless/hooks`)
223+
224+
| Hook | Signature (abridged) | Purpose |
225+
| ----------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------- |
226+
| `useControllableState` | `(controlled, defaultValue, onChange?) => [value, setValue]` | Dual-mode controlled/uncontrolled state; `onChange` fires either way. |
227+
| `useTransition` | `({ open, ref }) => { mounted, transitionStatus, transitionProps }` | Enter/exit lifecycle; spread `transitionProps` onto the animated part. |
228+
| `useTransitionStatus` | `(open) => { mounted, transitionStatus, setMounted }` | Lower-level phase machine (`'starting'` / `'ending'` / `undefined`). |
229+
| `useAnimationsFinished` | `(ref, open) => (cb) => void` | Runs `cb` once all CSS animations finish; aborts on rapid toggles. |
230+
| `useDataTable` | `(opts) => { rows, sorting, pagination, rowSelection, … }` | Table state (sort/filter/paginate/select), controlled or uncontrolled. |
231+
232+
## Shared utils (`@clerk/headless/utils`)
233+
234+
- **`useRender`, `mergeProps`, `ComponentProps<Tag>`, `DefaultProps<Tag>`, `RenderProp`** — the part-authoring primitives (above).
235+
- **`cssVars({ sideOffset? }): Middleware`** — floating-ui middleware setting
236+
`--cl-anchor-width/height`, `--cl-available-width/height`, `--cl-transform-origin`
237+
on the floating element. Place it **after** `arrow()`.
238+
- **`resetLayoutStyles(el): () => void`** — temporarily forces flex/grid
239+
alignment to `initial` for accurate `scrollHeight`/`scrollWidth` measurement;
240+
restores on next rAF. Call the returned cleanup from effect cleanup.
241+
242+
## Testing
243+
244+
Tests run in **real Chromium** (vitest browser mode), not jsdom, and include
245+
`axe` accessibility assertions. `pnpm test` in `packages/headless`. See
246+
`testing.md` for the Mosaic flow-layer testing model (a different concern — that
247+
covers machines/controllers/views, not these primitives).
Collapse file

‎packages/headless/README.md‎

Copy file name to clipboardExpand all lines: packages/headless/README.md
+4-4Lines changed: 4 additions & 4 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This package is **internal** (`private: true`) and consumed by `@clerk/ui`. It e
1919
| Tabs | `@clerk/headless/tabs` | Tab navigation with animated indicator |
2020
| Tooltip | `@clerk/headless/tooltip` | Hover/focus tooltip with configurable delay and group support |
2121

22-
Shared utilities are available at `@clerk/headless/utils` (includes `renderElement` and `mergeProps`).
22+
Shared utilities are available at `@clerk/headless/utils` (includes `useRender` and `mergeProps`).
2323

2424
Each primitive has its own README in `src/primitives/<name>/` with full API docs, props tables, keyboard navigation, and data attributes.
2525

@@ -50,7 +50,7 @@ All primitives follow the same compound component pattern. They emit zero styles
5050
## Architecture
5151

5252
- **Compound components** — each primitive exports a namespace (e.g. `Select.Trigger`, `Select.Popup`) backed by per-part files so unused parts tree-shake out
53-
- **`renderElement`** — every part uses this instead of returning JSX directly, enabling consumer `render` prop overrides and automatic state-to-data-attribute mapping
53+
- **`useRender`** — every part that renders a DOM element calls this hook instead of returning JSX directly, enabling consumer `render` prop overrides (function or element) and automatic state-to-data-attribute mapping
5454
- **`data-*` attributes** — state (`data-open`, `data-selected`, `data-active`) and animation lifecycle (`data-starting-style`, `data-ending-style`); parts are targeted by consumer-supplied classNames, not by an emitted slot attribute
5555
- **CSS-driven animations** — the transition system uses `data-*` attributes and the Web Animations API (`getAnimations().finished`) so all timing lives in CSS
5656
- **Floating UI** — positioning, interactions, focus management, dismiss handling, list navigation, and ARIA are all delegated to `@floating-ui/react`
@@ -117,9 +117,9 @@ Consumers can then style with the theme:
117117

118118
Without the annotation, `tsc` emits **TS2742**:
119119

120-
> The inferred type of `Dialog` cannot be named without a reference to `@clerk/headless/dist/utils/render-element`. This is likely not portable.
120+
> The inferred type of `Dialog` cannot be named without a reference to `@clerk/headless/dist/utils/use-render`. This is likely not portable.
121121
122-
`makeCustomizable<P>` returns an internal `CustomizablePrimitive<P>` type. When TS rolls up `.d.ts`, it resolves `DialogTriggerProps = ComponentProps<'button'>` back to its source file (`headless/dist/utils/render-element`), which **isn't in the package `exports` map**. The explicit `FunctionComponent<Customizable<DialogXProps>>` annotation forces TS to reference the named `DialogXProps` type from `@clerk/headless/dialog` (a public entry) instead of expanding it.
122+
`makeCustomizable<P>` returns an internal `CustomizablePrimitive<P>` type. When TS rolls up `.d.ts`, it resolves `DialogTriggerProps = ComponentProps<'button'>` back to its source file (`headless/dist/utils/use-render`), which **isn't in the package `exports` map**. The explicit `FunctionComponent<Customizable<DialogXProps>>` annotation forces TS to reference the named `DialogXProps` type from `@clerk/headless/dialog` (a public entry) instead of expanding it.
123123

124124
This applies to **every** headless primitive consumed through `makeCustomizable` — Popover, Tooltip, Menu, Select, etc. Each gets its own wrapper module under `packages/ui/src/primitives/<Name>.tsx` following the pattern above.
125125

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.