git:20260809.786f3c8 to git:20261016.c00a712

200 added, 414 removed. Audit A to A.

---
name: meta-design-composable-components
- description: Composable component APIs — parts, state, polymorphism
+ description: Composable component APIs — parts, state, polymorphism. Load when designing a reusable component's API, or aligning one that has grown boolean, renderX or style props.
---
- # Composable Components
+ # Composable Component Patterns
- > **Quick Guide:** Design component APIs the way headless primitive libraries do: a component owns behavior, state and accessibility -- the consumer owns markup and styling. Split configuration props into compound parts sharing scoped context, support controlled and uncontrolled use from the same API, let consumers substitute the rendered element (`asChild` or `render`), expose every state as a `data-*` attribute, and compose -- never replace -- the props, refs and handlers you receive. This is an alignment skill: run any existing component through the checklist at the end and fix what fails.
+ > **Quick Guide:** The component owns behaviour, state and accessibility; the consumer owns markup, element type and styling. That line settles most API questions: variation becomes parts and children instead of booleans and `renderX` props, each exposed state ships as `value` + `defaultValue` + `onValueChange` with a details argument carrying the reason and a veto, every part can render as the consumer's element through one substitution shape, state is published as `data-*` attributes, and whatever the consumer passes — props, handlers, refs — is composed rather than replaced. Three React 19 facts change the code: `ref` is an ordinary prop, a callback ref may return a cleanup that a ref-composing helper has to collect, and `useId`'s format changed in both 19.1 and 19.2, with only 19.2's safe in both a CSS selector and an XML id.
+ **Detailed Resources:**
+
+ - [examples/core.md](examples/core.md) — Patterns 1–3: compound parts, children with a descendant registry, context scoping
+ - [examples/state-contract.md](examples/state-contract.md) — Patterns 4–5: `useControllableState`, change details with reason and cancel, retrofitting the triple, state as `data-*` attributes
+ - [examples/polymorphism.md](examples/polymorphism.md) — Patterns 6–7: `asChild` and `render` built without a library, the merge they share, `composeEventHandlers`, `composeRefs`, prop ordering
+ - [examples/accessibility-structure.md](examples/accessibility-structure.md) — Pattern 8: id wiring, focus ownership, roving tabindex, typeahead, Escape
+ - [reference.md](reference.md) — prop-to-part translation, naming tables, substitution merge rules, attribute vocabulary, ARIA and keyboard contracts, the alignment checklist
+
---
- <critical_requirements>
+ ## Which path applies
- ## CRITICAL: Before Using This Skill
+ - **Designing a new component** — settle the parts (Patterns 1–3), then the state contract (4–5); substitution and forwarding (6–7) apply to every part you defined. Start at [examples/core.md](examples/core.md).
+ - **Aligning an existing component** — read its call sites, run it through the [alignment checklist](reference.md#alignment-checklist), and fix API shape first, because the later axes depend on which parts exist. Migrate additively, as [retrofitting the triple](examples/state-contract.md#retrofitting-an-existing-component) shows.
+ - **Building on a headless library that already ships parts** — use its substitution shape and its merge helper on every part you add, so yours behave like its own; the library's API is its own skill's subject, and [examples/polymorphism.md](examples/polymorphism.md) shows what any merge has to guarantee.
- > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
+ ---
- **(You MUST express variation as parts and children, NOT as configuration props -- a new visual requirement must be satisfiable by rearranging JSX, never by adding a boolean or a `renderX` prop)**
+ <critical_requirements>
- **(You MUST ship the full state triple for every piece of component state -- `value` + `defaultValue` + `onValueChange` -- and NEVER copy a controlled prop into internal state)**
+ ## Before designing a component API
- **(You MUST compose props, event handlers and refs that arrive from the consumer, NEVER replace them -- the consumer's handler runs first and must be able to suppress your internal behavior)**
+ **Express variation as parts and children, never as configuration props.** A boolean or `renderX` prop is the component deciding the consumer's markup, so each design need becomes another prop and N booleans become 2^N layouts to render and test; a part lets the consumer's JSX make that decision once.
- **(You MUST expose state as `data-*` attributes on every part and keep behavior parts visually unopinionated -- no default classNames, no inline colors, no baked-in transitions)**
+ **Ship every exposed piece of state as `value` + `defaultValue` + `onValueChange`, resolved in one hook.** A controlled prop copied into `useState` is read once and then drifts from the consumer's store; resolving to the prop whenever it is defined leaves no second copy to drift.
- **(You MUST read the component's current API and all of its call sites before changing it -- alignment is a refactor of a contract, and every consumer is part of that contract)**
+ **Compose what the consumer passes — props, handlers, refs — instead of replacing it.** Defaults go before the rest-spread and wiring after it, the consumer's handler runs first, and refs are merged; anything replaced disappears with no error and no type failure.
+ **Publish every state as a `data-*` attribute, and ship behaviour parts with no visual styling.** Attribute selectors let consumers style each state without the component knowing a class name or re-rendering; a default class, colour or transition is something every consumer has to out-specify.
+
+ **Read every call site before changing a component's API.** A removed prop whose name is also an HTML attribute — `title`, `hidden`, `color` — still type-checks once a part extends its element's props, and lands on the DOM node instead of failing the build.
+
</critical_requirements>
---
- **Auto-detection:** compound components, component API design, asChild, Slot, render prop, useRender, mergeProps, controlled uncontrolled, defaultValue, onValueChange, data-state, data attributes, headless component, primitive component, forwardRef, prop forwarding, composeRefs, composeEventHandlers, context scoping, roving tabindex, typeahead, focus trap, polymorphic component, children as composition, boolean prop explosion
+ **Auto-detection:** compound components, namespaced parts, `asChild`, `render` prop, `useControllableState`, `defaultValue`, `onValueChange`, `onOpenChange`, change details, `data-state`, `data-disabled`, `composeRefs`, `composeEventHandlers`, out-of-Root error, descendant registry, roving tabindex, typeahead, focus trap, polymorphic component, boolean prop explosion, `renderX` props, `items` prop, headless component
- **When to use:**
+ **Applies to:**
- - Designing the public API of a new reusable component
- - Aligning an existing component that has accumulated configuration props, booleans or `renderX` props
- - Deciding whether a new requirement becomes a prop, a part, or a slot
- - Adding controlled/uncontrolled duality to a component that only supports one mode
- - Making a component polymorphic so consumers can swap the rendered element
- - Moving styling decisions out of a component and into the consumer's stylesheet
- - Wiring accessibility structurally (ids, roles, focus, keyboard) instead of per-consumer
- - Reviewing a component library PR for API shape and forwarding discipline
+ - Deciding whether a new requirement becomes a prop, a part or children
+ - Designing the public API of a reusable component
+ - Aligning a component that has accumulated booleans, `renderX` props, style props or an `items` array
+ - Adding controlled/uncontrolled support, with a change callback that says why and can be refused
+ - Letting a part render as the consumer's element, and composing their props, handlers and refs onto it
+ - Publishing state for styling, and wiring ids, focus and keyboard behaviour across parts
+ - Reviewing a component change for API shape and forwarding discipline
- **When NOT to use:**
+ **Handled elsewhere:**
- - One-off application components rendered in exactly one place with no reuse pressure
- - Layout containers that genuinely take no state and no variation
- - Deciding _which_ primitive library to adopt -- this skill is about API shape, not tool selection
- - Visual design decisions: spacing scales, color systems, variant naming
+ - A headless library's own API — its parts, props, merge helper, escape hatch and version facts — is covered by that library's own skill. This skill settles the shape of what you build, on top of a library or without one.
+ - Which headless library to adopt.
+ - Accessibility conformance — WCAG audits, contrast, alt text, screen-reader testing. Accessibility appears here only where it decides which parts exist and what they share.
+ - Authoring styles — variant composition, tokens, theming. Styling appears here only as the contract a component exposes: `data-*` attributes, `className` and `style` passthrough, CSS custom properties.
+ - Visual design — spacing scales, colour systems, variant naming.
- **Key patterns covered:**
+ ---
- - Compound components over configuration props
- - Controlled/uncontrolled duality and the change-details object
- - Polymorphism: `asChild` + Slot, and the `render` prop + `useRender`
- - State as `data-*` attributes; zero visual opinions in behavior parts
- - Prop forwarding discipline: rest-spread, ref forwarding, handler composition
- - Context scoping and clear out-of-Root errors
- - Structural accessibility: id wiring, focus management, roving tabindex, typeahead
- - Children as composition, not `items={[...]}` configuration
+ <philosophy>
- ---
+ ## Philosophy
- ## Detailed Resources
+ A composable component draws one line and keeps to it:
- - [examples/core.md](examples/core.md) - Compound parts, children-as-composition, context scoping, the collection/registry problem
- - [examples/state-contract.md](examples/state-contract.md) - Controlled/uncontrolled hook, change details with reason and cancelation, state as data attributes
- - [examples/polymorphism.md](examples/polymorphism.md) - `asChild`/Slot, `render`/`useRender`, `composeRefs`, `composeEventHandlers`, merge rules
- - [examples/accessibility-structure.md](examples/accessibility-structure.md) - Id wiring, focus trap and restore, roving tabindex, typeahead
- - [reference.md](reference.md) - Prop-to-part translation, part and state naming, attribute vocabulary, ARIA and keyboard contracts
+ > **The component owns behaviour, state and accessibility. The consumer owns markup, element type and styling.**
- ---
+ Every defect this skill addresses is the component reaching across that line, and the API growing a prop to hand the job back through a narrow hole. `showCloseButton` exists because the component decided to render a close button, `padding="lg"` because it decided on spacing, `renderItem` because it decided on item markup. Composability hands the job back whole: `Dialog.Close` is not a smaller `showCloseButton` — it is the consumer's own button, anywhere in the tree, with close behaviour attached.
- <philosophy>
+ </philosophy>
- ## Philosophy
+ ---
- A composable component draws one line and never crosses it:
+ <decision_framework>
- > **The component owns behavior, state and accessibility. The consumer owns markup, element type and styling.**
+ ## Decision Framework
- Every defect this skill addresses is the same defect: the component reached across that line, and the API grew a prop to compensate. `showCloseButton` exists because the component decided to render a close button. `padding="lg"` exists because the component decided on spacing. `renderItem` exists because the component decided on item markup. Each one is a small piece of the consumer's job that the component took, then had to hand back through a narrow hole.
+ ### Prop, part or children?
- Composability is the opposite move: give the job back entirely. A `Dialog.Close` part is not a smaller `showCloseButton` -- it is the consumer rendering their own button, anywhere in the tree, with the close behavior attached to it.
+ ```
+ Does the requirement change what is RENDERED?
+ |-- NO (it changes behaviour or state) -> A prop.
+ +-- YES -> Can the consumer already express it by rearranging existing parts?
+ |-- YES -> Add nothing. Document the arrangement.
+ +-- NO -> Does it need the component's state or behaviour attached?
+ |-- YES -> A PART: a new subcomponent reading the shared context.
+ +-- NO -> The consumer's markup. Accept it as children.
+ ```
- **The two current expressions of one principle.** Element substitution is the clearest case of the line being respected, and two shapes for it are current:
+ A boolean prop is right only when it changes behaviour (`modal`, `disabled`, `loop`), never when it toggles markup.
- | Expression | Shape | Merging |
- | ----------------- | -------------------------------------------------- | ------------------------------------------------- |
- | `asChild` + Slot | `<Trigger asChild><a href="/x">Docs</a></Trigger>` | Clones the single child, merges props onto it |
- | `render` prop | `<Trigger render={<a href="/x">Docs</a>} />` | Clones the given element, merges props onto it |
- | `render` callback | `render={(props, state) => <a {...props} />}` | Hands you the props and the state; you place them |
+ ### Controlled, uncontrolled or both?
- They are the same idea with different ergonomics. `asChild` reads as "this part IS this child"; `render` reads as "render this part AS this element", and its callback form additionally exposes the component's state so a consumer can branch on it. Neither is a fallback for the other -- a component ships one of them, consistently, on every part.
+ ```
+ Does anything outside the component need to read or set this state?
+ |-- NEVER -> Keep it internal; expose nothing.
+ |-- SOMETIMES -> The triple: value + defaultValue + onValueChange.
+ | Uncontrolled by default, controlled when `value` is defined.
+ +-- ALWAYS (the component cannot compute it) -> Required value + onValueChange,
+ no defaultValue, documented as controlled-only.
+ ```
- **When to apply this skill:**
+ `value` alone cannot change and `defaultValue` alone cannot be observed, so neither ships without `onValueChange`.
- - The component has more than about three boolean props
- - A design change would require a new prop rather than different JSX
- - The component renders markup the consumer did not ask for
- - State lives only inside the component, or only outside it, but not both
- - The component's own tests are the only place its keyboard behavior is described
+ ### Which substitution shape?
- **When NOT to apply:**
+ ```
+ Does the library you are extending already define one?
+ |-- YES -> That one, on every part.
+ +-- NO -> Do consumers need the component's STATE to choose what to render?
+ |-- YES -> render={(props, state) => ...}
+ +-- NO -> asChild, or render={<El />}. Pick one: the value is that every part works alike.
+ ```
- - The component has one call site and no reuse pressure -- configuration props are cheaper than parts
- - The variation is genuinely closed (a `type="button" | "submit"` passthrough is not a boolean explosion)
- - Splitting into parts would produce parts that can never be rearranged -- if `Root > Header > Title` is the only legal tree, `title` may honestly be a prop
+ ### When configuration props are the right answer
- </philosophy>
+ - One call site and no reuse pressure — props are cheaper than parts.
+ - The variation is closed: a `type="button" | "submit"` passthrough is not a boolean explosion.
+ - The parts could never be rearranged — if `Root > Header > Title` is the only legal tree, `title` may honestly be a prop.
+ - A layout container that genuinely takes no state and no variation — it takes children and nothing else, so there are no parts to split and no state to expose.
+ </decision_framework>
+
---
<patterns>
- ## Core Patterns
+ ## Core patterns
- ### Pattern 1: Compound Components Over Configuration Props
+ ### Pattern 1: Compound Parts Over Configuration Props
- A monolith accepts the whole component as data. A compound component accepts it as JSX: a `Root` that owns state and publishes it through context, and parts that subscribe. The consumer decides which parts exist, in what order, wrapped in what.
+ A `Root` owns state and publishes it through context; parts subscribe, and the consumer decides which parts exist, in what order, wrapped in what.
```tsx
- // Monolith: every new layout need becomes a new prop
- <Dialog title="Delete" description="Permanent." showCloseButton size="lg" renderFooter={renderActions} />
+ // Monolith: each layout need is a new prop
+ <Dialog title="Delete" showCloseButton size="lg" renderFooter={renderActions} />
- // Compound: layout is JSX, the component still owns behavior
+ // Parts: layout is JSX; Root still owns open state, dismissal, focus and aria-* wiring
<Dialog.Root>
<Dialog.Trigger>Delete</Dialog.Trigger>
- <Dialog.Portal>
- <Dialog.Backdrop />
- <Dialog.Popup>
- <Dialog.Title>Delete</Dialog.Title>
- <Dialog.Close>Cancel</Dialog.Close>
- </Dialog.Popup>
- </Dialog.Portal>
+ <Dialog.Popup>
+ <Dialog.Title>Delete</Dialog.Title>
+ <Dialog.Close>Cancel</Dialog.Close>
+ </Dialog.Popup>
</Dialog.Root>
```
- **Why good:** A footer above the title, two close buttons, a form wrapping the popup -- all are rearrangements, not API changes. The `Root` still owns open state, dismissal, focus and `aria-*` wiring, so nothing accessible was traded away for the flexibility.
-
- **Why the monolith is bad:** `title` forces the component to choose the heading level and its position. `showCloseButton` forces it to choose the button's markup, label and placement. `renderFooter` is `children` with a worse signature and no access to the parts' context. Each prop is a permanent commitment resolvable only by adding another prop.
-
- > **Full before/after, including the context and part implementations:** See [examples/core.md](examples/core.md).
-
- ---
+ Full code: [examples/core.md](examples/core.md#pattern-1-compound-parts-over-configuration-props)
- ### Pattern 2: Controlled/Uncontrolled Duality
+ ### Pattern 2: Children as Composition
- Every piece of state a component owns ships as a triple: `value` (controlled), `defaultValue` (uncontrolled initial), and `onValueChange` (always called, in both modes). The component is uncontrolled by default so the common case needs no state at all.
+ An `items` array makes the component own every item's markup — icons, badges, grouping, empty states — one prop at a time. Children hand that back while each `Item` registers with the `Root`, which keeps selection, keyboard navigation and `aria-activedescendant`. The cost is a registry ordered by DOM position, since mount order stops matching DOM order the first time an item renders late.
```tsx
- const [open, setOpen] = useControllableState({
- value: props.open,
- defaultValue: props.defaultOpen ?? false,
- onChange: props.onOpenChange,
- });
+ <Select.Popup>
+ <Select.Group>
+ <Select.GroupLabel>Frameworks</Select.GroupLabel>
+ <Select.Item value="react">
+ <Select.ItemText>React</Select.ItemText> <Badge>new</Badge>
+ </Select.Item>
+ </Select.Group>
+ </Select.Popup>
```
- **Why good:** One resolved value, one setter, one source of truth. `onOpenChange` fires in both modes, so analytics and side effects attach the same way regardless of who owns the state. A consumer converts from uncontrolled to controlled by adding two props, not by rewriting call sites.
+ Full code: [examples/core.md](examples/core.md#pattern-2-children-as-composition)
- **Why the alternatives are bad:** `open` with no `onOpenChange` produces a component that can never close itself -- the consumer must reimplement outside-press and Escape. `useState(props.open)` copies the prop once and then drifts silently. Mode-switching mid-life (`value` going from `undefined` to defined) changes which state wins between renders and desynchronizes the DOM; decide the mode at mount and warn if it changes.
+ ### Pattern 3: Context Scoping
- **The change-details argument.** A bare `(value) => void` tells the consumer _what_ changed but not _why_, and gives them no way to refuse. Passing a details object solves both:
+ One context per primitive, provided per `Root`, created with a `null` default and read through a guard that names the part — so an orphaned part throws instead of rendering a button that does nothing.
```tsx
- onOpenChange={(open, details) => {
- if (details.reason === "outside-press" && hasUnsavedEdits) details.cancel();
- }}
- ```
+ const DialogContext = createContext<DialogContextValue | null>(null);
- **Why good:** `reason` lets side effects be conditional (close-by-Escape and close-by-submit are different events). `cancel()` lets the consumer veto the state change without hoisting the state, which is the only alternative in a bare-callback API.
+ function useDialogContext(part: string): DialogContextValue {
+ const context = useContext(DialogContext);
+ if (context === null)
+ throw new Error(`<Dialog.${part}> must be rendered inside <Dialog.Root>.`);
+ return context;
+ }
+ ```
- > **Full `useControllableState` implementation and details object:** See [examples/state-contract.md](examples/state-contract.md).
+ Memoize the provided value — by hand, or with the React Compiler running over the component's source — or every part re-renders on every `Root` render.
- ---
+ Full code: [examples/core.md](examples/core.md#pattern-3-context-scoping)
- ### Pattern 3: Polymorphism -- `asChild` and `render`
+ ### Pattern 4: Controlled and Uncontrolled State
- A component that hardcodes its element type forces wrappers. A trigger that must be a link, a menu item that must be a router link, a heading whose level depends on nesting -- all need element substitution, and both current shapes work by cloning an element the consumer supplies and merging the component's props onto it.
+ One hook resolves the triple to a single value and setter, and calls `onValueChange` in both modes, so consumer side effects attach the same way whoever owns the state. The callback's second argument says why the change happened and lets the consumer refuse it without taking control.
```tsx
- // asChild form: the part becomes its child
- import { Slot } from "radix-ui";
-
- const Comp = asChild ? Slot.Root : "button";
- return <Comp {...rest} ref={forwardedRef} />;
-
- // render form: the part renders as the given element
- import { useRender } from "@base-ui/react/use-render";
- import { mergeProps } from "@base-ui/react/merge-props";
-
- return useRender({
- defaultTagName: "button",
- render,
- props: mergeProps<"button">(internalProps, rest),
+ const [isOpen, setIsOpen] = useControllableState({
+ value: open,
+ defaultValue: defaultOpen,
+ onChange: onOpenChange,
+ componentName: "<Dialog.Root>",
});
- ```
- **Why good:** The consumer's element keeps its own semantics (`<a href>` stays a link, is focusable, and works with the router) while gaining the component's behavior, `aria-*` wiring and state attributes. No wrapper element is introduced, so layout and CSS selectors are unaffected.
-
- **The merging contract -- the part every implementation gets wrong.** Substitution is only safe if all three are merged rather than overwritten:
-
- | What | Rule |
- | --------------- | ----------------------------------------------------------------------------------------------- |
- | **Handlers** | The consumer's handler runs first; the component's internal handler runs after and is skippable |
- | **Refs** | Both refs receive the node -- the component needs it for measurement and focus restore |
- | **Class/style** | Concatenated and shallow-merged, with the consumer's values winning on conflict |
-
- The escape hatch differs by library and this is the single most confusable fact in this area: with Slot-based composition the consumer calls `event.preventDefault()` and the primitive's composed handler checks `defaultPrevented` before running; with Base UI's merged props the consumer calls `event.preventBaseUIHandler()`, which skips Base UI's internal handler _without_ calling `preventDefault()` or `stopPropagation()`.
-
- **Why hand-rolled substitution is bad:** `React.cloneElement(child, props)` overwrites the child's `onClick`, drops the child's `ref`, and replaces `className` instead of concatenating. The result renders fine, passes type-check, and silently breaks the consumer's handler.
-
- > **Both APIs in full, plus dependency-free `composeRefs`/`composeEventHandlers`:** See [examples/polymorphism.md](examples/polymorphism.md).
+ <Dialog.Root
+ onOpenChange={(open, details) => {
+ if (details.reason === "outside-press" && hasUnsavedEdits) details.cancel();
+ }}
+ />;
+ ```
- ---
+ Full code: [examples/state-contract.md](examples/state-contract.md#pattern-4-controlled-and-uncontrolled-state)
- ### Pattern 4: State as `data-*` Attributes, Zero Visual Opinions
+ ### Pattern 5: State as Data Attributes
- Every state the component computes is published on the DOM as a data attribute. Styling then happens in the consumer's stylesheet against `[data-state="open"]` or `[data-disabled]` -- no state needs to travel back out through props.
+ Enumerated states are attribute values, boolean states are attribute presence, and the consumer's stylesheet does the rest. Behaviour parts set no class, colour, spacing or transition; the only inline styles they may set are functionally load-bearing — computed coordinates, measured sizes — exposed as CSS custom properties where possible, so the consumer's stylesheet decides how they apply and can override them.
```tsx
<button
data-state={open ? "open" : "closed"}
data-side={side}
- {...(disabled ? { "data-disabled": "" } : null)}
+ data-disabled={disabled ? "" : undefined}
/>
```
- **Why good:** Enumerated states become attribute _values_ (`data-state="open" | "closed"`), boolean states become attribute _presence_. The consumer styles hover, open and disabled without the component knowing a single class name, and without re-rendering on every visual state change.
+ Full code: [examples/state-contract.md](examples/state-contract.md#pattern-5-state-as-data-attributes)
+ ### Pattern 6: Element Substitution
+
+ A part that hardcodes its element forces wrappers — a `<button>` around an `<a>` is invalid nesting and lands in the consumer's layout. Substitution clones the consumer's element and merges the part's props, handlers, ref and state attributes onto it, so the element keeps its own semantics.
+
```tsx
- // Bad: a boolean state written as a value
- <button data-disabled={disabled} /> // renders data-disabled="false"
- ```
+ // asChild: the part becomes its single child
+ <Tooltip.Trigger asChild><a href="/docs">Docs</a></Tooltip.Trigger>
- **Why bad:** `[data-disabled]` matches an element whose attribute is the string `"false"`, so every disabled style applies to enabled elements. Attribute presence is the boolean; omit the attribute entirely when the state is off.
+ // render, element form: the part renders as the given element
+ <Tooltip.Trigger render={<a href="/docs" />}>Docs</Tooltip.Trigger>
- **Zero visual opinions.** A behavior part renders no default `className`, no colors, no spacing, no transitions. The only styles it may set inline are the ones that are functionally load-bearing -- computed position coordinates, `transform` for a thumb, measured sizes -- and even those belong in CSS custom properties where possible, so the consumer can override them.
+ // render, callback form: the consumer places the props and reads the state
+ <Tooltip.Trigger
+ render={(props, state) => <a href="/docs" {...props}>{state.open ? "Hide" : "Docs"}</a>}
+ />
+ ```
- > **Full attribute vocabulary, state-driven `className`/`style` functions, and exit-animation attributes:** See [examples/state-contract.md](examples/state-contract.md).
+ The merge is the contract: the consumer's handler runs first and can suppress the internal one, both refs receive the node, `className` concatenates and `style` merges with the consumer winning.
- ---
+ Full code: [examples/polymorphism.md](examples/polymorphism.md#pattern-6-element-substitution)
- ### Pattern 5: Prop Forwarding Discipline
+ ### Pattern 7: Prop Forwarding
- A part is a DOM element with behavior attached. Anything the consumer puts on it -- `id`, `aria-label`, `data-testid`, `className`, `onKeyDown`, `tabIndex` -- must reach the DOM node. Destructure only what you consume; spread the rest.
+ A part is a DOM element with behaviour attached: destructure what it consumes, spread the rest, and let position in the JSX encode policy.
```tsx
<button
- type="button" // default: overridable
- {...rest} // consumer's props
- ref={composeRefs(forwardedRef, localRef)} // non-negotiable
+ type="button" // default: before the spread, so the consumer can override it
+ {...rest}
+ ref={composeRefs(ref, localRef)} // wiring: after the spread, so nothing clobbers it
aria-expanded={open}
data-state={open ? "open" : "closed"}
- onClick={composeEventHandlers(rest.onClick, handleClick)}
+ onClick={composeEventHandlers(rest.onClick, toggle)}
/>
```
- **Why good:** Ordering encodes intent. Defaults sit before the spread so the consumer can override them. Non-negotiables -- the composed ref, the `aria-*` wiring, the composed handlers -- sit after the spread so a stray prop cannot silently break accessibility. Nothing the consumer passes is swallowed.
-
- ```tsx
- // Bad: an allowlist API pretending to be a DOM element
- function Trigger({ children, onClick }: TriggerProps) {
- return <button onClick={onClick}>{children}</button>;
- }
- ```
-
- **Why bad:** `id`, `className`, `aria-label`, `data-testid` and every other prop vanish with no error. Consumers add a wrapper `<div>` to attach what they need, which breaks the CSS selectors and the flex/grid layout the trigger was sitting in. The ref never arrives, so focus restore and positioning measurement fail.
-
- > **Ref forwarding across React versions and the full composition helpers:** See [examples/polymorphism.md](examples/polymorphism.md).
-
- ---
-
- ### Pattern 6: Context Scoping and Clear Out-of-Root Errors
-
- Parts communicate with their `Root` through a context created per-primitive and provided per-`Root` instance -- never a module-level store. Reading that context is always guarded, and the guard names the part.
-
- ```tsx
- const DialogContext = createContext<DialogContextValue | null>(null);
-
- function useDialogContext(part: string): DialogContextValue {
- const context = useContext(DialogContext);
- if (context === null) {
- throw new Error(`<Dialog.${part}> must be rendered inside <Dialog.Root>.`);
- }
- return context;
- }
- ```
-
- **Why good:** The `null` default makes misuse impossible to miss, and the message names both the offending part and the fix. Two dialogs on the same page have two providers, so nesting resolves by React's normal context shadowing rather than by an id-matching scheme.
-
- **Why a default value object is bad:** `createContext(defaultValue)` makes an orphaned `Dialog.Close` render a button that does nothing -- no error, no warning, and a bug that only shows up in manual testing. Silent no-ops are worse than crashes in a component library.
-
- **Memoize the value.** The context value is rebuilt every render unless memoized, and every part re-renders with it. Memoize on the state it actually contains, and keep setters stable with `useCallback` or `useRef` so they are not part of the dependency list.
-
- > **Full provider, per-part consumers, and the descendant-registry pattern:** See [examples/core.md](examples/core.md).
-
- ---
+ Full code: [examples/polymorphism.md](examples/polymorphism.md#pattern-7-prop-forwarding)
- ### Pattern 7: Structural Accessibility
+ ### Pattern 8: Structural Accessibility
- Accessibility that depends on the consumer passing the right `aria-*` props is accessibility that will be wrong. A composable component wires it structurally: it generates ids, connects them across parts through context, and owns the keyboard and focus behavior its role requires.
+ Accessibility that depends on the consumer passing the right `aria-*` props will be wrong. The component generates ids and wires them across parts through context, and owns the focus and keyboard behaviour its role requires: focus in on open and back on close, a Tab trap while modal, roving tabindex, typeahead and Escape — with `onOpenAutoFocus`/`onCloseAutoFocus`-style hooks so consumers redirect focus without forking.
```tsx
- // Title generates its id and registers it with the Root; Popup consumes it,
- // and renders no aria-labelledby at all when no Title is present.
- const generatedId = useId();
+ // A labelling part registers its id while mounted, so aria-labelledby is absent
+ // when no Title renders instead of pointing at nothing.
const id = props.id ?? generatedId;
-
useEffect(() => {
registerTitleId(id);
return () => registerTitleId(undefined);
}, [id, registerTitleId]);
```
- **Why good:** The relationship survives every rearrangement of the parts, because it flows through context rather than through the DOM tree the consumer wrote. Registration also means the attribute is _absent_ when the part is absent, instead of pointing at an id that never rendered.
-
- **What "structural" covers, per role:**
-
- | Concern | Owned by the component |
- | ------------------ | -------------------------------------------------------------------------------------------- |
- | **Labeling** | Generated ids, `aria-labelledby`/`aria-describedby` wired via context |
- | **Focus** | Move focus in on open, restore to the trigger on close, trap while modal |
- | **Arrow keys** | Roving tabindex -- exactly one item is tabbable, arrows move the active one |
- | **Typeahead** | Buffered printable characters, matched against item text, reset on idle |
- | **Escape hatches** | `onOpenAutoFocus`/`onCloseAutoFocus`-style hooks so consumers redirect focus without forking |
-
- **Why bad without it:** A dialog that does not restore focus leaves the keyboard user at the top of the document. A listbox that makes every option tabbable turns one Tab press into forty. Neither is visible in a screenshot, and neither is the consumer's job to discover.
-
- > **Id registration, focus trap and restore, roving tabindex and typeahead implementations:** See [examples/accessibility-structure.md](examples/accessibility-structure.md).
-
- ---
-
- ### Pattern 8: Children as Composition, Not `items={[...]}`
-
- An `items` array makes the component responsible for rendering every item, which means it is responsible for icons, badges, descriptions, grouping, empty states, keys and i18n -- forever, one prop at a time. Children hand all of it back.
-
- ```tsx
- // Config: the component owns item markup, so every design need is a new prop
- <Select items={options} renderItem={renderOption} groupBy="category" showIcons />
-
- // Composition: the consumer owns markup, the component still owns behavior
- <Select.Popup>
- <Select.Group>
- <Select.GroupLabel>Frameworks</Select.GroupLabel>
- <Select.Item value="react">React <Badge>new</Badge></Select.Item>
- </Select.Group>
- </Select.Popup>
- ```
-
- **Why good:** Anything renderable is an item's content. Grouping is markup rather than a `groupBy` string. The component keeps ownership of selection, keyboard navigation and `aria-activedescendant`, because each `Item` registers itself with the `Root`.
-
- **The cost, stated honestly:** with an `items` array the component knows the order for free; with children it must build a registry. Register each item's DOM node on mount and sort the registry by `compareDocumentPosition`, never by mount order -- mount order and DOM order diverge under conditional rendering, portals and Suspense, and index-based registration silently misroutes arrow keys after any reorder.
-
- > **Item registry, DOM-order sorting, and the value/label problem:** See [examples/core.md](examples/core.md).
+ Full code: [examples/accessibility-structure.md](examples/accessibility-structure.md#pattern-8-structural-accessibility)
</patterns>
---
- <integration>
-
- ## Scope Boundaries
-
- This skill is about the **shape of a component's API**. It is deliberately not about:
-
- | Out of scope | Belongs to |
- | ------------------------------------------------ | ------------------------------------- |
- | Using the primitive libraries themselves | `web-ui-radix-ui`, `web-ui-base-ui` |
- | WCAG conformance, screen reader testing at large | `web-accessibility-web-accessibility` |
- | Variant styling and class composition | `web-styling-cva` |
- | Design tokens, theming, color systems | their own styling skills |
-
- Accessibility appears here only where it is _structural_ -- id wiring, focus ownership, keyboard behavior -- because those decisions are API decisions: they determine what parts exist and what context they share. Contrast ratios, alt text and audit workflows are not.
-
- Styling appears here only as a _contract_ -- what a component must expose (`data-*`, `className` passthrough, CSS custom properties) so that styling is possible at all. Which styling tool consumes that contract is not this skill's concern.
-
- </integration>
-
- ---
-
<red_flags>
- ## RED FLAGS
-
- **High Priority Issues:**
-
- - **Boolean prop explosion** (`showCloseButton`, `hideOverlay`, `withIcon`, `noPadding`) -- N booleans is 2^N states the component must render correctly and someone must test; the next design will need the one combination that was never considered. Each boolean is a part that was not extracted.
- - **`isOpen` with no `onOpenChange`** -- the component can be opened but can never close itself; every consumer reimplements Escape and outside-press, inconsistently, and dismissal accessibility is lost.
- - **Copying a controlled prop into state** (`useState(props.value)`) -- reads correctly on first render and drifts forever after; the DOM shows stale state while the consumer's store shows the truth.
- - **Style props as API** (`padding`, `bgColor`, `width`, `margin`) -- makes the behavior component a design system with a worse type signature; a second product theme requires forking it.
- - **`renderX` prop multiplication** (`renderHeader`, `renderFooter`, `renderItem`, `renderEmpty`) -- these are `children` with less power: no context access, no rearrangement, and a new one for every region.
- - **Unforwarded refs** -- positioning, measurement, focus restore, scroll-into-view and every consumer integration break at once, with no error; the component just quietly stops behaving.
- - **Replaced instead of composed handlers** -- `onClick={props.onClick}` on a trigger deletes the open behavior; `onClick={handleOpen}` deletes the consumer's. Either way the failure is silent.
- - **Visual opinions in behavior parts** -- a default `className`, a hardcoded `transition`, an inline color: the consumer must now out-specify the component's own CSS to style it.
+ ## Red flags
- **Medium Priority Issues:**
+ **Breaks at runtime, silently:**
- - Parts that only work in one arrangement -- if `Trigger` must be the first child of `Root`, the split gained nothing over props.
- - Context created with a default value object instead of `null` -- turns misuse into a silent no-op.
- - Unmemoized context values -- every part re-renders on every `Root` render.
- - Index-based item registration -- correct until the first conditional item, then arrow keys land on the wrong row.
- - A component that generates ids but never lets a consumer supply one -- breaks external `aria-controls` and label wiring.
- - Mixing polymorphism shapes (`asChild` on some parts, `render` on others) -- consumers cannot predict either.
+ - `open` with no `onOpenChange` — the component can open but never close itself, so every consumer reimplements Escape and outside press — ship the triple.
+ - `useState(props.value)` — right on the first render, stale after the first external change — resolve to the prop whenever it is defined.
+ - `onValueChange` called only in uncontrolled mode — the consumer's side effects vanish the moment they take control — call it in both.
+ - A controlled prop that is `undefined` on the first render (`value={data?.selected}`) — the component starts uncontrolled and switches, so the value freezes or jumps back — decide the mode at mount and warn when it changes.
+ - `onClick={props.onClick}` or `onClick={handleOpen}` on a part — one deletes the component's behaviour, the other the consumer's — `composeEventHandlers(props.onClick, handleOpen)`.
+ - `{...rest}` spread after the `aria-*` wiring and composed handlers — a stray consumer prop overwrites them — defaults before the spread, wiring after.
+ - A part that picks its props from an allowlist (`{ children, onClick }`) — `id`, `aria-label`, `data-testid`, `className` and the ref vanish, and consumers add a wrapper that breaks their layout — spread the rest.
+ - A ref accepted and never attached — positioning, measurement and focus restore stop working with no error — attach it, composed with any internal ref.
+ - `React.cloneElement(child, props)` as hand-rolled substitution — the child's `onClick`, `className` and `style` are replaced, and a passed `ref` replaces the child's — merge with `composeEventHandlers` and `composeRefs`.
+ - A substitution target that does not spread its props and receive `ref` — the part renders and never opens anything — require both of any component used with `asChild` or `render`.
+ - `data-disabled={disabled}` — React writes `data-*` and `aria-*` booleans as the strings `"true"` and `"false"`, so `[data-disabled]` matches enabled elements — `data-disabled={disabled ? "" : undefined}`.
+ - `createContext(defaultValue)` for part context — an orphaned part renders and does nothing — default to `null` and throw from the guard.
+ - Items registered by mount index — arrow keys land on the wrong row after the first late-rendered item — sort the registry by `compareDocumentPosition`.
+ - A `composeRefs` that ignores what callback refs return — the composed ref returns nothing, so React calls it with `null` and an inner ref's cleanup never runs — collect the cleanups, and return a composed one only when some ref supplied one, because React 18 logs an error for any callback ref that returns a function.
- **Common Mistakes:**
+ **Grows the API with every design change:**
- - Spreading `{...rest}` _after_ the `aria-*` and composed handlers, letting a stray consumer prop overwrite the wiring -- defaults go before the spread, non-negotiables after.
- - Adding a part that renders its own wrapper `<div>` "for convenience" -- it lands in the middle of the consumer's flex layout and cannot be removed.
- - Exporting parts as separate top-level components (`DialogTrigger`, `DialogClose`) with no namespace, so nothing communicates that they belong to a `Root`.
- - Treating `children` as `ReactNode` when the component needs to inspect it -- inspection via `React.Children.map` breaks under fragments, portals and any wrapper; use a context registry instead.
- - Firing `onValueChange` only in uncontrolled mode -- the consumer's logging and side effects vanish the moment they take control.
+ - Booleans that toggle markup (`showCloseButton`, `hideOverlay`, `withIcon`) — 2^N layouts, and the next design needs the combination nobody built — extract the part.
+ - `renderX` props (`renderHeader`, `renderFooter`, `renderItem`) — `children` with no context access and no rearrangement — accept children on a part.
+ - Style props as API (`padding`, `bgColor`, `width`) — the behaviour component becomes a design system with a worse signature — accept the consumer's `className`.
+ - Default classes, colours or transitions in behaviour parts — consumers must out-specify the component's CSS to style it — ship none.
+ - Parts that only work in one arrangement — if `Trigger` must be `Root`'s first child, the split bought nothing over props.
+ - A part that renders its own wrapper `<div>` for convenience — it lands inside the consumer's flex or grid layout and cannot be removed — one element per part.
+ - Parts exported as unrelated top-level components (`DialogTrigger`) — nothing at the call site says they need a `Root` — export a namespace.
+ - Two substitution shapes in one library (`asChild` here, `render` there) — consumers can predict neither — one shape on every part.
+ - Generated ids with no consumer override — external `aria-controls` and label wiring become impossible — `props.id ?? generatedId`.
- **Gotchas & Edge Cases:**
+ **Surprising behaviour:**
- - **Slot merges a single child only.** Multiple children need an explicit `Slottable` marker so the merge targets the right element; without it, cloning throws or targets the wrong node.
- - **`asChild` with a component that does not spread props is silently non-functional.** No error is raised -- the trigger simply never opens anything. The same is true of the element form of `render`.
- - **React 19 callback refs may return a cleanup function.** A `composeRefs` helper that ignores return values leaks the old node; collect the cleanups and return a composed cleanup.
- - **`useId` values are not selector-safe or XML-safe.** They were `:r0:` before React 19.1 and are `«r0»` from 19.1 on; colons break unescaped CSS selectors, and guillemets are invalid in SVG `id` attributes and throw in some `querySelector` implementations. Generated ids are fine in `aria-*` and `for`/`id` on HTML elements -- never build a selector string or an SVG id from one.
- - **Changing `defaultValue` after mount does nothing** -- it is read once, by design. Consumers expecting it to reset the component need an explicit `key` change or a reset method.
- - **`data-side`/`data-align` are written after measurement,** so they can flip between first paint and layout effect. Drive entry animations off explicit starting/ending-style attributes rather than mount.
- - **Escape hatches differ by shape:** `event.preventDefault()` suppresses a Slot-composed internal handler; `event.preventBaseUIHandler()` suppresses a Base UI internal handler without preventing the default action. `preventBaseUIHandler` exists only on React synthetic events -- where the library listens natively, it has no effect.
- - **Portaled content is out of DOM order.** Focus management, not `aria-owns`, is what keeps the experience coherent; a portal without a focus contract is worse than no portal.
+ - An `asChild` slot merges onto exactly one element child and throws on more — a part that renders decoration around its content cannot slot it; offer the `render` callback form there.
+ - Suppressing a part's internal handler depends on the merge. An `asChild` slot leaves it to the part's composed handler, which skips when the consumer calls `event.preventDefault()`; a merge helper that wraps events may expose its own skip method instead, which leaves the default action alone and exists only on React's synthetic events, not on value callbacks. Document the one your parts honour.
+ - Changing `defaultValue` after mount does nothing — it is read once; resetting needs a `key` change or a reset method.
+ - `data-side` and `data-align` are written after measurement and can differ between first paint and the positioning layout effect — drive entry animations off a starting-style attribute, not off mount.
+ - `useId` output is internal and has changed shape: 19.0's `:r0:` needs escaping in a CSS selector, 19.1's `«r0»` is not a valid XML name, and 19.2's `_r_` prefix is valid in both. Keep a ref to the element, or `CSS.escape` the id, rather than splicing it into a selector.
+ - `React.Children.map` to inspect children breaks under fragments, wrappers and conditional children — a context registry does not.
+ - Portaled content is out of DOM order — the focus contract, not `aria-owns`, is what keeps it coherent.
+ - A focus trap in a non-modal popover strands the user, who expects Tab to move on to the page — gate the trap on `modal`.
</red_flags>
-
- ---
-
- <decision_framework>
-
- ## Decision Framework
-
- ### Prop, Part, or Slot?
-
- ```
- Does the new requirement change what is RENDERED?
- |-- NO (it changes behavior or state) -> It is a prop. Ship it as a prop.
- +-- YES -> Can the consumer already express it by rearranging existing parts?
- |-- YES -> Add nothing. Document the arrangement.
- +-- NO -> Does it need the component's state or behavior attached?
- |-- YES -> Add a PART (a new subcomponent reading the shared context).
- +-- NO -> It is the consumer's markup. Accept it as children.
- ```
-
- A boolean prop is the correct answer only when it changes behavior (`modal`, `disabled`, `loop`) -- never when it toggles the existence of markup.
-
- ### Controlled, Uncontrolled, or Both?
-
- ```
- Does anything outside the component need to read or set this state?
- |-- NEVER -> Keep it internal. Do not expose it at all.
- +-- SOMETIMES -> Ship the triple: value + defaultValue + onValueChange.
- Uncontrolled by default, controlled when `value` is provided.
- +-- ALWAYS (the component cannot compute it) -> Required `value` + `onValueChange`,
- no defaultValue. Document that it is controlled-only.
- ```
-
- Never ship `value` alone, and never ship `defaultValue` alone -- the first cannot change, the second cannot be observed.
-
- ### Which Polymorphism Shape?
-
- ```
- Does the component library you are extending already define one?
- |-- YES -> Use that one, on every part, without exception.
- +-- NO -> Do consumers need the component's STATE to decide what to render?
- |-- YES -> Callback form: render={(props, state) => ...}
- +-- NO -> Element form: asChild + Slot, or render={<El />}. Pick one and
- apply it uniformly -- the value is predictability, not the shape.
- ```
-
- ### The Alignment Checklist
-
- Run any existing component through these six axes. Each failed line is a concrete refactor, in this order -- API shape first, because the later axes depend on which parts exist.
-
- **1. API shape**
-
- - [ ] No boolean prop toggles the existence of markup
- - [ ] No `renderX` prop exists that `children` on a part could not express
- - [ ] Every region a consumer might want to change is a part or a child, not a prop
- - [ ] Parts can be rearranged, omitted, duplicated and wrapped without breaking behavior
- - [ ] Parts are namespaced (`Dialog.Trigger`) so their `Root` is obvious
-
- **2. State contract**
-
- - [ ] Every exposed state ships as `value` + `defaultValue` + `onValueChange`
- - [ ] The component is usable with zero state props (uncontrolled by default)
- - [ ] No controlled prop is copied into `useState`
- - [ ] The change callback fires in both modes
- - [ ] The change callback carries a reason, and a cancelation path exists for vetoable changes
-
- **3. Polymorphism**
-
- - [ ] Every part that renders a DOM element supports element substitution
- - [ ] One shape (`asChild` or `render`) is used consistently across all parts
- - [ ] Substitution merges rather than overwrites props, refs, `className` and `style`
- - [ ] The documented escape hatch for suppressing internal handlers is correct for the shape used
-
- **4. Styling contract**
-
- - [ ] Every state is readable from the DOM as a `data-*` attribute
- - [ ] Boolean states are attribute presence, never `="false"`
- - [ ] No part ships a default `className`, color, spacing or transition
- - [ ] Inline styles are limited to functionally required values, exposed as CSS custom properties where possible
- - [ ] `className` and `style` from the consumer always reach the DOM node
-
- **5. Accessibility structure**
-
- - [ ] Ids are generated and wired across parts through context, not through props
- - [ ] `aria-labelledby`/`aria-describedby` are absent when the labeling part is absent
- - [ ] Focus moves in on open and is restored to the trigger on close
- - [ ] Composite widgets use roving tabindex -- exactly one tabbable item
- - [ ] Consumers can override focus behavior through hooks rather than by forking
-
- **6. Forwarding**
-
- - [ ] Every part spreads its remaining props onto its DOM node
- - [ ] Every part forwards its ref to that node
- - [ ] Consumer handlers run first and can suppress the internal handler
- - [ ] Defaults are placed before the spread, non-negotiables after
- - [ ] Nothing the consumer passes is silently dropped
-
- </decision_framework>
-
- ---
-
- <critical_reminders>
-
- ## CRITICAL REMINDERS
-
- > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
-
- **(You MUST express variation as parts and children, NOT as configuration props -- a new visual requirement must be satisfiable by rearranging JSX, never by adding a boolean or a `renderX` prop)**
-
- **(You MUST ship the full state triple for every piece of component state -- `value` + `defaultValue` + `onValueChange` -- and NEVER copy a controlled prop into internal state)**
-
- **(You MUST compose props, event handlers and refs that arrive from the consumer, NEVER replace them -- the consumer's handler runs first and must be able to suppress your internal behavior)**
-
- **(You MUST expose state as `data-*` attributes on every part and keep behavior parts visually unopinionated -- no default classNames, no inline colors, no baked-in transitions)**
-
- **(You MUST read the component's current API and all of its call sites before changing it -- alignment is a refactor of a contract, and every consumer is part of that contract)**
-
- **Failure to follow these rules will produce a component that has to be forked or wrapped the first time a design changes -- the exact failure composable APIs exist to prevent.**
-
- </critical_reminders>