|
| 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). |
0 commit comments