meta-design-composable-components
Composable component APIs — parts, state, polymorphism
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/meta-design-composable-components/skills/meta-design-composable-components
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Composable Components
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 (
asChildorrender), expose every state as adata-*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.
<critical_requirements>
CRITICAL: Before Using This Skill
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)
</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
When to use:
- Designing the public API of a new reusable component
- Aligning an existing component that has accumulated configuration props, booleans or
renderXprops - 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
When NOT to use:
- 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
Key patterns covered:
- Compound components over configuration props
- Controlled/uncontrolled duality and the change-details object
- Polymorphism:
asChild+ Slot, and therenderprop +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
Detailed Resources
- examples/core.md - Compound parts, children-as-composition, context scoping, the collection/registry problem
- examples/state-contract.md - Controlled/uncontrolled hook, change details with reason and cancelation, state as data attributes
- examples/polymorphism.md -
asChild/Slot,render/useRender,composeRefs,composeEventHandlers, merge rules - examples/accessibility-structure.md - Id wiring, focus trap and restore, roving tabindex, typeahead
- reference.md - Prop-to-part translation, part and state naming, attribute vocabulary, ARIA and keyboard contracts
<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. isOpenwith noonOpenChange-- 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. renderXprop multiplication (renderHeader,renderFooter,renderItem,renderEmpty) -- these arechildrenwith 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 hardcodedtransition, an inline color: the consumer must now out-specify the component's own CSS to style it.
Medium Priority Issues:
- Parts that only work in one arrangement -- if
Triggermust be the first child ofRoot, 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
Rootrender. - 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-controlsand label wiring. - Mixing polymorphism shapes (
asChildon some parts,renderon others) -- consumers cannot predict either.
Common Mistakes:
- Spreading
{...rest}after thearia-*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 aRoot. - Treating
childrenasReactNodewhen the component needs to inspect it -- inspection viaReact.Children.mapbreaks under fragments, portals and any wrapper; use a context registry instead. - Firing
onValueChangeonly in uncontrolled mode -- the consumer's logging and side effects vanish the moment they take control.
Gotchas & Edge Cases:
- Slot merges a single child only. Multiple children need an explicit
Slottablemarker so the merge targets the right element; without it, cloning throws or targets the wrong node. asChildwith 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 ofrender.- React 19 callback refs may return a cleanup function. A
composeRefshelper that ignores return values leaks the old node; collect the cleanups and return a composed cleanup. useIdvalues 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 SVGidattributes and throw in somequerySelectorimplementations. Generated ids are fine inaria-*andfor/idon HTML elements -- never build a selector string or an SVG id from one.- Changing
defaultValueafter mount does nothing -- it is read once, by design. Consumers expecting it to reset the component need an explicitkeychange or a reset method. data-side/data-alignare 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.preventBaseUIHandlerexists 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.
</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
renderXprop exists thatchildrenon 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 theirRootis 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 (
asChildorrender) is used consistently across all parts - Substitution merges rather than overwrites props, refs,
classNameandstyle - 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
-
classNameandstylefrom 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-describedbyare 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>
Files (skills)
-
examples
-
accessibility-structure.md 15.9 KB
# Composable Components - Accessibility Structure > The accessibility work that is an API decision: generated ids wired across parts, focus ownership with consumer escape hatches, roving tabindex, and typeahead. See [SKILL.md](../SKILL.md) for the alignment checklist and [core.md](core.md) for the context and registry these build on. **Prerequisites**: Understand context scoping and the descendant registry from [core.md](core.md) -- every pattern here is a `Root` publishing behavior that individual parts subscribe to. > **Scope:** this file covers accessibility that determines _what parts exist and what they share_. Conformance testing, contrast, alt text and audit workflows are a different domain. --- ## Id Wiring Through Context A relationship between two parts cannot be expressed by the consumer, because the consumer does not know the ids and should not have to. The `Root` owns them; the parts register and consume. ```tsx interface RegisteredIds { readonly labelId: string | undefined; readonly descriptionId: string | undefined; readonly registerLabelId: (id: string | undefined) => void; readonly registerDescriptionId: (id: string | undefined) => void; } function useRegisteredId( register: (id: string | undefined) => void, suppliedId: string | undefined, ): string { const generatedId = useId(); const id = suppliedId ?? generatedId; useEffect(() => { register(id); return () => register(undefined); }, [register, id]); return id; } ``` ```tsx const DialogDescription = forwardRef< HTMLParagraphElement, React.ComponentPropsWithoutRef<"p"> >(function DialogDescription(props, forwardedRef) { const { registerDescriptionId } = useDialogContext("Description"); const id = useRegisteredId(registerDescriptionId, props.id); return <p {...props} ref={forwardedRef} id={id} />; }); ``` **Why good:** The relationship survives every rearrangement, because it travels through context rather than through the JSX tree the consumer wrote. The attribute is `undefined` when the part is not rendered, so `aria-describedby` never points at an id that does not exist. A consumer-supplied `id` wins, which is what makes external `aria-controls` and form-label wiring possible. **Why generating the id in the popup is bad:** ```tsx // BAD: the popup assumes a title exists and guesses its id const titleId = `${dialogId}-title`; <div role="dialog" aria-labelledby={titleId}> ``` If no `Dialog.Title` is rendered, `aria-labelledby` points at nothing. Assistive technology follows the reference, finds no element, and reports no accessible name -- a strictly worse outcome than omitting the attribute, which would at least fall back to other naming sources. ### Require a Name, Don't Invent One ```tsx useEffect(() => { if (process.env.NODE_ENV === "production") return; const hasName = labelId !== undefined || ariaLabel !== undefined || ariaLabelledBy !== undefined; if (!hasName) { console.error( "<Dialog.Popup> has no accessible name. Render a <Dialog.Title>, or pass aria-label to <Dialog.Popup>.", ); } }, [labelId, ariaLabel, ariaLabelledBy]); ``` **Why good:** The component knows the requirement and the two ways to satisfy it, so it states both. Development-only, so the check costs nothing in production. A component that silently ships unnamed dialogs has moved a requirement it understands onto a consumer who does not. --- ## Focus Ownership A component that takes over the screen owns focus: where it goes on open, where it returns on close, and what happens to Tab in between. Consumers override through hooks, not by forking. ```tsx const FOCUS_RESTORE_FRAME_DELAY = 0; interface UseFocusManagementParams { readonly open: boolean; readonly popupRef: React.RefObject<HTMLElement | null>; readonly onOpenAutoFocus?: (event: Event) => void; readonly onCloseAutoFocus?: (event: Event) => void; } function useFocusManagement({ open, popupRef, onOpenAutoFocus, onCloseAutoFocus, }: UseFocusManagementParams): void { const previouslyFocusedRef = useRef<HTMLElement | null>(null); useEffect(() => { if (!open) return; previouslyFocusedRef.current = document.activeElement as HTMLElement | null; const openEvent = new Event("openAutoFocus", { cancelable: true }); onOpenAutoFocus?.(openEvent); if (!openEvent.defaultPrevented) { focusFirstTabbable(popupRef.current); } return () => { const closeEvent = new Event("closeAutoFocus", { cancelable: true }); onCloseAutoFocus?.(closeEvent); if (closeEvent.defaultPrevented) return; const target = previouslyFocusedRef.current; window.setTimeout( () => target?.focus({ preventScroll: true }), FOCUS_RESTORE_FRAME_DELAY, ); }; }, [open, popupRef, onOpenAutoFocus, onCloseAutoFocus]); } const TABBABLE_SELECTOR = [ "a[href]", "button:not([disabled])", "input:not([disabled]):not([type='hidden'])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", "[contenteditable='true']", ].join(","); function getTabbables(container: HTMLElement | null): readonly HTMLElement[] { if (container === null) return []; const matches = [ ...container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR), ]; return matches.filter( (element) => element.offsetParent !== null && !element.closest("[inert]"), ); } function focusFirstTabbable(container: HTMLElement | null): void { const [first] = getTabbables(container); if (first !== undefined) { first.focus({ preventScroll: true }); return; } if (container === null) return; container.setAttribute("tabindex", "-1"); container.focus({ preventScroll: true }); } ``` **The visibility filter is not optional:** the selector matches elements inside `display: none` subtrees, inside `inert` regions and inside collapsed `<details>`, none of which are tabbable. A trap built on the unfiltered list sends focus to an invisible element and looks, to the user, like focus disappeared. **Why good:** The cancelable-event escape hatch matches the escape hatch used everywhere else in a composable API -- `preventDefault()` on an event the component hands you. A consumer who wants focus on the second field, or wants focus to land on a newly created row instead of the trigger, does it in four lines and keeps every other behavior. The container fallback guarantees focus never escapes to `<body>`, which would strand the keyboard user at the top of the document. **Why the deferred restore:** focusing synchronously during unmount competes with the browser's own focus handling and with any exit animation still holding the element. Deferring by a frame lets the DOM settle so the trigger actually receives focus. **Why "restore to `document.activeElement` at open" beats "restore to the trigger":** the component may have been opened programmatically from a menu item, a keyboard shortcut or a toast. Whatever had focus is what the user expects to return to. ### Trapping Tab ```tsx function handleKeyDown(event: React.KeyboardEvent): void { if (event.key !== "Tab") return; const tabbables = getTabbables(popupRef.current); if (tabbables.length === 0) { event.preventDefault(); return; } const first = tabbables[0]; const last = tabbables[tabbables.length - 1]; const active = document.activeElement; if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); return; } if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } } ``` **Why good:** The list is recomputed on every Tab, so content that appears while the dialog is open is included. Trapping only at the boundaries leaves normal Tab behavior -- including the browser's own ordering rules -- untouched in between. **Why a trap is only for modal surfaces:** trapping focus in a non-modal popover strands the user, who legitimately expects Tab to move on to the page. Gate the trap on the `modal` prop, and make that prop's default match the role. --- ## Roving Tabindex In a composite widget -- listbox, menu, toolbar, tab list -- the whole widget is one tab stop, and arrow keys move within it. ```tsx type Orientation = "horizontal" | "vertical"; type MoveDirection = "next" | "previous" | "first" | "last"; const ORIENTATION_KEYS = { horizontal: { next: "ArrowRight", previous: "ArrowLeft" }, vertical: { next: "ArrowDown", previous: "ArrowUp" }, } as const satisfies Record<Orientation, { next: string; previous: string }>; interface UseRovingFocusParams { readonly orientation: Orientation; readonly loop: boolean; readonly getOrderedItems: () => readonly Descendant[]; } function useRovingFocus({ orientation, loop, getOrderedItems, }: UseRovingFocusParams) { const [activeId, setActiveId] = useState<string | null>(null); const moveFocus = useCallback( (direction: MoveDirection) => { const items = getOrderedItems().filter((item) => !item.disabled); if (items.length === 0) return; const currentIndex = items.findIndex((item) => item.id === activeId); const nextIndex = resolveIndex( direction, currentIndex, items.length, loop, ); const next = items[nextIndex]; if (next === undefined) return; setActiveId(next.id); next.element.focus({ preventScroll: true }); next.element.scrollIntoView({ block: "nearest" }); }, [activeId, getOrderedItems, loop], ); const onKeyDown = useCallback( (event: React.KeyboardEvent) => { const keys = ORIENTATION_KEYS[orientation]; if (event.key === keys.next) moveFocus("next"); else if (event.key === keys.previous) moveFocus("previous"); else if (event.key === "Home") moveFocus("first"); else if (event.key === "End") moveFocus("last"); else return; event.preventDefault(); }, [orientation, moveFocus], ); return { activeId, onKeyDown }; } function resolveIndex( direction: MoveDirection, currentIndex: number, count: number, loop: boolean, ): number { if (direction === "first") return 0; if (direction === "last") return count - 1; const nextIndex = currentIndex + (direction === "next" ? 1 : -1); if (nextIndex < 0) return loop ? count - 1 : 0; if (nextIndex >= count) return loop ? 0 : count - 1; return nextIndex; } ``` Each item is tabbable only when it is the active one: ```tsx <div role="option" tabIndex={isActive ? 0 : -1} data-highlighted={isActive ? "" : undefined} /> ``` **Why good:** Tab enters and leaves the widget in one press regardless of item count. Disabled items are filtered before indexing, so they are skipped without a gap in the sequence. `preventDefault` runs only for keys that were handled, so unhandled keys still reach the consumer and the browser. `scrollIntoView({ block: "nearest" })` keeps the active item visible without yanking the viewport when it already is. **Why every-item-tabbable is bad:** a forty-option listbox becomes forty tab stops. Keyboard users cannot get past the widget, and screen reader users lose the "one control" mental model the role promises. **Why index state is bad:** storing the active _index_ rather than the active _id_ breaks the moment the list is filtered or reordered -- the index still points somewhere, just not at the item the user was on. Ids survive reordering. ### Focus Versus `aria-activedescendant` | Approach | Focus lives on | Use when | | ----------------------- | --------------- | ----------------------------------------------------------------- | | Roving tabindex | The active item | Items are the interactive elements (menu, toolbar, tab list) | | `aria-activedescendant` | The container | A text input must keep focus while a list is navigated (combobox) | Pick per widget role. Mixing them -- moving DOM focus _and_ setting `aria-activedescendant` -- makes screen readers announce twice. --- ## Typeahead Any list a user can navigate by arrow keys should also be navigable by typing. ```tsx const TYPEAHEAD_RESET_MS = 500; interface TypeaheadItem extends Descendant { /** Registered by the ItemText part -- never read from element.textContent. */ readonly textValue: string; } function useTypeahead( getOrderedItems: () => readonly TypeaheadItem[], activeId: string | null, onMatch: (item: TypeaheadItem) => void, ) { const searchRef = useRef(""); const timeoutRef = useRef<number | undefined>(undefined); return useCallback( (event: React.KeyboardEvent) => { if (!isPrintableCharacter(event)) return; searchRef.current += event.key.toLowerCase(); window.clearTimeout(timeoutRef.current); timeoutRef.current = window.setTimeout(() => { searchRef.current = ""; }, TYPEAHEAD_RESET_MS); const items = getOrderedItems().filter((item) => !item.disabled); const match = findByPrefix(items, searchRef.current, activeId); if (match === undefined) return; onMatch(match); event.preventDefault(); }, [getOrderedItems, activeId, onMatch], ); } function findByPrefix( items: readonly TypeaheadItem[], search: string, activeId: string | null, ): TypeaheadItem | undefined { // A repeated character cycles through items starting with it, as native listboxes do. const isCycling = isRepeatedCharacter(search); const prefix = isCycling ? search.slice(0, 1) : search; // Refining a multi-character search may stay on the current item; a fresh // single character or a cycle must advance past it. Both wrap. const activeIndex = items.findIndex((item) => item.id === activeId); const startIndex = isCycling || search.length === 1 ? activeIndex + 1 : Math.max(activeIndex, 0); for (let offset = 0; offset < items.length; offset += 1) { const item = items[(startIndex + offset) % items.length]; if (item !== undefined && item.textValue.toLowerCase().startsWith(prefix)) return item; } return undefined; } function isRepeatedCharacter(search: string): boolean { return ( search.length > 1 && [...search].every((character) => character === search[0]) ); } function isPrintableCharacter(event: React.KeyboardEvent): boolean { return ( event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey ); } ``` **Why good:** The buffer accumulates within the reset window, so "se" finds "Settings" rather than jumping to the first item starting with "e". Modifier combinations are excluded so `Ctrl+S` is not swallowed. The scan starts from the active item and wraps, so repeatedly typing "s" cycles through every item starting with "s" instead of pinning the first one. `preventDefault` fires only on a match, so a space that matched nothing still activates the focused item. **Where the text comes from:** the item's rendered content may include badges, icons and descriptions, so `element.textContent` is the wrong source. This is exactly why an `ItemText` part exists -- the consumer marks which text is the label, and typeahead reads that. Falling back to `textContent` makes typeahead match a badge. --- ## Escape and Dismissal Ownership ```tsx useEffect(() => { if (!open) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setOpen(false, createChangeEventDetails("escape-key", event)); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [open, setOpen]); ``` **Why good:** The listener is on `document`, so Escape works even when focus is inside a portaled child or a nested iframe-free subtree. The change is routed through the same `setOpen` as every other dismissal, so it carries a reason and honors `cancel()`. **The nesting problem:** two open layers both listening on `document` both close on one Escape. The fix is an ownership stack -- each layer registers on open, and only the top of the stack handles the key. Nesting is the normal case for dialogs containing selects, so a component that owns Escape must also own the stack. -
core.md 16.8 KB
# Composable Components - Core Patterns > Compound components over configuration props, children as composition, context scoping with clear out-of-Root errors, and the descendant registry that makes children-as-composition work. See [SKILL.md](../SKILL.md) for decision frameworks and [reference.md](../reference.md) for the translation tables. **Helpers used here and defined elsewhere:** `useControllableState` and `createChangeEventDetails` in [state-contract.md](state-contract.md); `composeEventHandlers` and `composeRefs` in [polymorphism.md](polymorphism.md). --- ## Compound Components: Before/After ### Before: The Configuration Monolith ```tsx interface DialogProps { readonly open: boolean; readonly onClose: () => void; readonly title: string; readonly description?: string; readonly size?: "sm" | "md" | "lg"; readonly showCloseButton?: boolean; readonly closeButtonLabel?: string; readonly hideBackdrop?: boolean; readonly renderFooter?: (close: () => void) => React.ReactNode; readonly children: React.ReactNode; } function Dialog({ open, onClose, title, description, size = "md", showCloseButton = true, closeButtonLabel = "Close", hideBackdrop = false, renderFooter, children, }: DialogProps) { if (!open) return null; return ( <div className="dialog-layer"> {!hideBackdrop && <div className="dialog-backdrop" onClick={onClose} />} <div className={`dialog dialog--${size}`} role="dialog" aria-modal="true"> <header className="dialog__header"> <h2 className="dialog__title">{title}</h2> {showCloseButton && ( <button aria-label={closeButtonLabel} onClick={onClose}> x </button> )} </header> {description && <p className="dialog__description">{description}</p>} <div className="dialog__body">{children}</div> {renderFooter && ( <footer className="dialog__footer">{renderFooter(onClose)}</footer> )} </div> </div> ); } ``` **Why bad:** Nine props to render one dialog, and none of them cover the next request. The heading is locked to `h2`. The close button is locked to the header, to that markup, and to that position. `renderFooter` is `children` with a callback bolted on because the footer needs `onClose` -- a hole punched through the API because context was not used. Every class name is a visual decision the consumer cannot undo without out-specifying it. There is no `defaultOpen`, so a dialog that manages itself is impossible. Focus is never moved or restored. ### After: Compound Parts Sharing Context ```tsx import { createContext, forwardRef, useContext, useEffect, useId, useMemo, useState, } from "react"; type DialogChangeReason = | "trigger-press" | "close-press" | "backdrop-press" | "escape-key"; type DialogChangeDetails = ChangeEventDetails<DialogChangeReason>; interface DialogContextValue { readonly open: boolean; readonly setOpen: (open: boolean, details: DialogChangeDetails) => void; readonly triggerId: string; readonly titleId: string | undefined; readonly registerTitleId: (id: string | undefined) => void; } 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; } interface DialogRootProps { readonly open?: boolean; readonly defaultOpen?: boolean; readonly onOpenChange?: (open: boolean, details: DialogChangeDetails) => void; readonly children: React.ReactNode; } function DialogRoot({ open, defaultOpen = false, onOpenChange, children, }: DialogRootProps) { const [isOpen, setIsOpen] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange, componentName: "<Dialog.Root>", }); const [titleId, registerTitleId] = useState<string | undefined>(undefined); const triggerId = useId(); const context = useMemo<DialogContextValue>( () => ({ open: isOpen, setOpen: setIsOpen, triggerId, titleId, registerTitleId, }), [isOpen, setIsOpen, triggerId, titleId], ); return ( <DialogContext.Provider value={context}>{children}</DialogContext.Provider> ); } ``` The parts are thin: each reads the context, wires its own element, and forwards everything else. ```tsx const DialogTrigger = forwardRef< HTMLButtonElement, React.ComponentPropsWithoutRef<"button"> >(function DialogTrigger(props, forwardedRef) { const { open, setOpen, triggerId } = useDialogContext("Trigger"); return ( <button type="button" {...props} ref={forwardedRef} id={props.id ?? triggerId} aria-haspopup="dialog" aria-expanded={open} data-state={open ? "open" : "closed"} onClick={composeEventHandlers(props.onClick, (event) => setOpen( true, createChangeEventDetails("trigger-press", event.nativeEvent), ), )} /> ); }); const DialogPopup = forwardRef< HTMLDivElement, React.ComponentPropsWithoutRef<"div"> >(function DialogPopup(props, forwardedRef) { const { open, titleId } = useDialogContext("Popup"); if (!open) return null; return ( <div {...props} ref={forwardedRef} role="dialog" aria-modal="true" aria-labelledby={titleId} data-state="open" /> ); }); const DialogTitle = forwardRef< HTMLHeadingElement, React.ComponentPropsWithoutRef<"h2"> >(function DialogTitle(props, forwardedRef) { const { registerTitleId } = useDialogContext("Title"); const generatedId = useId(); const id = props.id ?? generatedId; useEffect(() => { registerTitleId(id); return () => registerTitleId(undefined); }, [id, registerTitleId]); return <h2 {...props} ref={forwardedRef} id={id} />; }); const DialogClose = forwardRef< HTMLButtonElement, React.ComponentPropsWithoutRef<"button"> >(function DialogClose(props, forwardedRef) { const { setOpen } = useDialogContext("Close"); return ( <button type="button" {...props} ref={forwardedRef} onClick={composeEventHandlers(props.onClick, (event) => setOpen( false, createChangeEventDetails("close-press", event.nativeEvent), ), )} /> ); }); // DialogPortal and DialogBackdrop follow the same shape: read the context, // render one element, forward everything else. const Dialog = { Root: DialogRoot, Trigger: DialogTrigger, Portal: DialogPortal, Backdrop: DialogBackdrop, Popup: DialogPopup, Title: DialogTitle, Close: DialogClose, }; export { Dialog }; export type { DialogChangeDetails, DialogChangeReason, DialogRootProps }; ``` **Why good:** Every prop that disappeared came back as a rearrangement. `showCloseButton` is "render a `Dialog.Close` or don't". `closeButtonLabel` is the consumer's own `aria-label` on their own button. `hideBackdrop` is "render a `Dialog.Backdrop` or don't". `size` is the consumer's class name on `Dialog.Popup`. `renderFooter` is children -- and a `Dialog.Close` inside that footer gets `setOpen` from context, so the callback hole is gone. The heading level, the markup, the order and the wrappers are all the consumer's. The `Root` still owns open state, the id relationship between `Title` and `Popup`, and the `aria-*` wiring, so nothing accessible was traded for the flexibility. **Note the id registration.** `aria-labelledby` is `undefined` when no `Dialog.Title` is rendered, rather than pointing at an id that does not exist. A dangling `aria-labelledby` leaves the dialog with no accessible name at all -- worse than omitting it, because assistive technology stops looking. --- ## Usage: Two Layouts, No API Change ```tsx // Self-managing: no state in the consumer at all <Dialog.Root> <Dialog.Trigger>Settings</Dialog.Trigger> <Dialog.Portal> <Dialog.Backdrop /> <Dialog.Popup> <Dialog.Title>Settings</Dialog.Title> <SettingsForm /> <Dialog.Close>Done</Dialog.Close> </Dialog.Popup> </Dialog.Portal> </Dialog.Root> // Controlled, opened from elsewhere, with a form wrapping the popup <Dialog.Root open={isConfirming} onOpenChange={setIsConfirming}> <Dialog.Portal> <Dialog.Popup> <form onSubmit={handleDelete}> <Dialog.Title>Delete project</Dialog.Title> <footer> <Dialog.Close>Cancel</Dialog.Close> <button type="submit">Delete</button> </footer> </form> </Dialog.Popup> </Dialog.Portal> </Dialog.Root> ``` Neither layout required an API change, and the second one -- a `<form>` between the popup and its content, with the close button in a footer the component never knew about -- is the exact shape that forces a monolith to grow a prop. --- ## Children as Composition, Not `items={[...]}` ### Before: The Items Array ```tsx interface SelectOption { readonly value: string; readonly label: string; readonly icon?: React.ReactNode; readonly description?: string; readonly disabled?: boolean; readonly group?: string; } interface SelectProps { readonly options: readonly SelectOption[]; readonly value: string; readonly onChange: (value: string) => void; readonly groupBy?: "group"; readonly renderOption?: (option: SelectOption) => React.ReactNode; readonly emptyMessage?: string; readonly showIcons?: boolean; } ``` **Why bad:** `SelectOption` is a private markup language. Every design request extends it -- a badge, a trailing shortcut, a two-line layout, a nested group -- and every extension is a breaking change to a type that every call site constructs. `renderOption` exists to escape the type, but it renders outside the component's context, so a custom option cannot reach selection state without it being passed back through the callback. `groupBy` reimplements nesting as a string. `emptyMessage` reimplements conditional rendering as a prop. ### After: Items as Children ```tsx <Select.Root value={framework} onValueChange={setFramework}> <Select.Trigger> <Select.Value placeholder="Pick a framework" /> </Select.Trigger> <Select.Portal> <Select.Positioner> <Select.Popup> <Select.Group> <Select.GroupLabel>Stable</Select.GroupLabel> <Select.Item value="react"> <ReactMark /> <Select.ItemText>React</Select.ItemText> <Badge tone="neutral">18k</Badge> </Select.Item> </Select.Group> {results.length === 0 && <p role="status">Nothing matches</p>} </Select.Popup> </Select.Positioner> </Select.Portal> </Select.Root> ``` **Why good:** The badge, the icon, the empty state and the grouping are all ordinary JSX. `Select.ItemText` marks which text is the item's label for typeahead and for the trigger's value display, which is the one piece of structure the component genuinely needs -- so it asks for exactly that, and nothing else. Selection, keyboard navigation and `aria-activedescendant` stay with the `Root`. --- ## The Descendant Registry Children-as-composition costs the component its free knowledge of item order. It has to build the collection itself, and it has to sort by DOM position rather than mount order. ```tsx interface Descendant { readonly id: string; readonly element: HTMLElement; readonly value: string; readonly disabled: boolean; } interface CollectionContextValue { readonly register: (descendant: Descendant) => () => void; readonly getOrderedItems: () => readonly Descendant[]; } function useCollection(): CollectionContextValue { const itemsRef = useRef(new Map<string, Descendant>()); const register = useCallback((descendant: Descendant) => { itemsRef.current.set(descendant.id, descendant); return () => { itemsRef.current.delete(descendant.id); }; }, []); const getOrderedItems = useCallback(() => { return [...itemsRef.current.values()].sort(byDocumentPosition); }, []); return useMemo( () => ({ register, getOrderedItems }), [register, getOrderedItems], ); } function byDocumentPosition(a: Descendant, b: Descendant): number { const relation = a.element.compareDocumentPosition(b.element); if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1; return 0; } ``` Each item registers itself once its node exists, and unregisters on unmount: ```tsx const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>( function SelectItem({ value, disabled = false, ...props }, forwardedRef) { const { register } = useSelectContext("Item"); const id = useId(); const localRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { const element = localRef.current; if (element === null) return; return register({ id, element, value, disabled }); }, [register, id, value, disabled]); return ( <div role="option" {...props} ref={composeRefs(forwardedRef, localRef)} id={id} data-value={value} {...(disabled ? { "data-disabled": "" } : null)} /> ); }, ); ``` **Why good:** Order is read from the DOM at the moment it is needed, so conditional items, portals, Suspense boundaries and reordered lists all resolve correctly. Registration returns its own cleanup, so there is no separate unregister call to forget. The item's own ref still reaches the consumer through `composeRefs`. **Why mount-order registration is bad:** ```tsx // BAD: index assigned at mount time const index = itemsRef.current.length; itemsRef.current.push({ value, index }); ``` The first conditional item breaks it. Mount order equals DOM order only until something renders late -- a lazily loaded group, an item behind a Suspense boundary, or an item that re-mounts after a filter. Arrow keys then move to a position that does not match what the user sees, and nothing throws. --- ## Context Scoping and Out-of-Root Errors ### The Guard ```tsx const SelectContext = createContext<SelectContextValue | null>(null); function useSelectContext(part: string): SelectContextValue { const context = useContext(SelectContext); if (context === null) { throw new Error( `<Select.${part}> must be rendered inside <Select.Root>. ` + `Wrap the tree containing <Select.${part}> in <Select.Root>.`, ); } return context; } ``` **Why good:** `null` is the only default that cannot be mistaken for a working component. The message names the offending part -- not just "Select" -- so the fix is obvious in a tree with a dozen parts, and it says what to do rather than only what went wrong. ### The Anti-Pattern ```tsx // BAD: a default that silently no-ops const SelectContext = createContext<SelectContextValue>({ value: "", setValue: () => {}, register: () => () => {}, }); ``` **Why bad:** An orphaned `Select.Item` renders, styles correctly, responds to hover, and does nothing when clicked. There is no error and no warning. The bug reaches production because the component _looks_ right in every screenshot and every snapshot test. ### Memoizing the Value ```tsx // BAD: new object identity every render -- every part re-renders return ( <SelectContext.Provider value={{ value, setValue, register }}> {children} </SelectContext.Provider> ); // GOOD: identity changes only when the state does const context = useMemo( () => ({ value, setValue, register }), [value, setValue, register], ); return ( <SelectContext.Provider value={context}>{children}</SelectContext.Provider> ); ``` For this to be worth doing, the functions in the value must themselves be stable -- `useCallback` with a stable dependency list, or a `useRef`-held mutable box for handlers that change every render. An unstable `setValue` makes the `useMemo` decorative. ### Splitting Hot and Cold Context When one value changes on every keystroke and another never changes, one context makes the stable consumers re-render with the volatile ones. ```tsx // Cold: identities and callbacks -- effectively never changes const SelectActionsContext = createContext<SelectActions | null>(null); // Hot: the highlighted item -- changes on every arrow key const SelectHighlightContext = createContext<string | null>(null); ``` **Why good:** `Select.Trigger` and `Select.Group` read only the cold context and stop re-rendering during keyboard navigation. Only the items subscribed to the hot context update. Do this when a profile shows the cost -- two contexts are two things to keep in sync, and a component with five items does not need it. ### Nesting Resolves Itself A `Root` inside another `Root` needs no special handling: React context shadowing already gives the inner parts the inner provider. The problem worth designing for is the opposite one -- a part of primitive A rendered inside primitive B, where both want to own the Escape key or the focus. Solve that with explicit ownership (an inner-most-wins dismissal stack), not by making the contexts aware of each other. -
polymorphism.md 16 KB
# Composable Components - Polymorphism and Forwarding > Element substitution in both current shapes (`asChild` + Slot, `render` + `useRender`), the merge rules each one uses, and the dependency-free `composeRefs` / `composeEventHandlers` helpers that make substitution safe. See [SKILL.md](../SKILL.md) for the decision framework and [reference.md](../reference.md) for the side-by-side merge table. **Prerequisites**: Understand compound parts from [core.md](core.md) -- polymorphism is a property of individual parts, applied uniformly across all of them. --- ## The Problem Substitution Solves ```tsx // Without substitution: a wrapper appears in the layout and in every selector <Tooltip.Trigger> <a href="/docs">Docs</a> </Tooltip.Trigger> // Renders: <button ...tooltip wiring><a href="/docs">Docs</a></button> ``` **Why bad:** A `<button>` wrapping an `<a>` is invalid interactive nesting, breaks keyboard semantics, and inserts an element into the consumer's flex row that their CSS did not account for. The tooltip's `aria-describedby` and its focus and hover handlers land on the button, not on the link the user actually interacts with. --- ## Shape A: `asChild` + Slot ```tsx import { Slot } from "radix-ui"; import { forwardRef } from "react"; interface TooltipTriggerProps extends React.ComponentPropsWithoutRef<"button"> { /** Merge this part's props and behavior onto its single child instead of rendering a button. */ readonly asChild?: boolean; } const TooltipTrigger = forwardRef<HTMLButtonElement, TooltipTriggerProps>( function TooltipTrigger({ asChild = false, ...rest }, forwardedRef) { const { open, describedById, onTriggerEnter } = useTooltipContext("Trigger"); const Component = asChild ? Slot.Root : "button"; return ( <Component type={asChild ? undefined : "button"} {...rest} ref={forwardedRef} aria-describedby={open ? describedById : undefined} data-state={open ? "open" : "closed"} onPointerEnter={composeEventHandlers( rest.onPointerEnter, onTriggerEnter, )} /> ); }, ); ``` ```tsx <Tooltip.Trigger asChild> <a href="/docs">Docs</a> </Tooltip.Trigger> // Renders: <a href="/docs" aria-describedby="..." data-state="closed" ...>Docs</a> ``` **Why good:** No wrapper element exists. The link keeps its href, its focusability and its router integration, and gains the tooltip's wiring. `type="button"` is suppressed when substituting, because it is a `<button>` default that would be meaningless -- or invalid -- on the substituted element. **Requirements on the child, both mandatory and both silently failing when unmet:** ```tsx // The child must spread props and forward its ref const MyLink = forwardRef< HTMLAnchorElement, React.ComponentPropsWithoutRef<"a"> >(function MyLink(props, forwardedRef) { return <a {...props} ref={forwardedRef} />; }); ``` **Why bad without them:** a child that destructures only `children` and `href` swallows `aria-describedby`, the data attributes and the pointer handlers. The tooltip renders, type-checks, and never appears. A child that drops the ref breaks positioning measurement and focus restore with equally no signal. ### Multiple Children: `Slottable` `Slot.Root` merges onto exactly one child. When a part must render siblings around the slotted element, mark which one receives the props: ```tsx import { Slot } from "radix-ui"; <Slot.Root {...props}> <LeadingIcon /> <Slot.Slottable>{children}</Slot.Slottable> <TrailingIcon /> </Slot.Root>; ``` **Why good:** The icons stay ordinary siblings while the consumer's element still receives the merged props and ref. Without `Slottable`, a multi-child Slot has no way to know which child is the target. A render-function form covers the case where the slotted child must be wrapped in markup the part owns: ```tsx <Slot.Slottable child={children}> {(child) => <span className="ButtonInner">{child}</span>} </Slot.Slottable> ``` ### Typed Slots ```tsx const AnchorSlot = Slot.createSlot< HTMLAnchorElement, React.ComponentPropsWithoutRef<"a"> >("Tooltip.Slot"); ``` **Why good:** Consumers get accurate prop types for the substituted element instead of the generic slot type, so a typo in `href` is a compile error rather than a swallowed prop. --- ## Shape B: The `render` Prop + `useRender` ```tsx import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; interface TextProps extends useRender.ComponentProps<"p"> {} function Text(props: TextProps) { const { render, ...otherProps } = props; return useRender({ defaultTagName: "p", render, props: mergeProps<"p">({ className: "text" }, otherProps), }); } ``` ```tsx <Text>Rendered as a paragraph</Text> <Text render={<strong />}>Rendered as a strong element</Text> ``` **Why good:** `useRender` is the same merging machinery the library's own parts use, so a hand-written component behaves identically to a built-in one. `defaultTagName` keeps the no-`render` case ergonomic. `useRender.ComponentProps<"p">` supplies the prop types including `render` itself. ### The Callback Form ```tsx <Popover.Trigger render={(props, state) => ( <button {...props}> {state.open ? "Hide details" : "Show details"} <Chevron data-state={state.open ? "open" : "closed"} /> </button> )} /> ``` **Why good:** The consumer places the props themselves, so they can split them across elements, and they receive the component's state directly instead of mirroring it. Use this form when the rendered _content_ depends on state; the element form is enough when only the element type changes. ### `useRender` Parameters | Parameter | Purpose | | ------------------------ | -------------------------------------------------------------------------- | | `defaultTagName` | Element rendered when no `render` prop is supplied | | `render` | Element or `(props, state) => ReactElement` callback | | `props` | Props merged onto the element -- handlers, `className` and `style` combine | | `state` | Passed to the callback and converted to `data-*` attributes automatically | | `ref` | A ref or an **array** of refs -- this is where composition happens | | `enabled` | Skip rendering entirely | | `stateAttributesMapping` | Override how `state` keys become attributes | The `ref` array matters: `mergeProps` does **not** merge refs -- only the rightmost survives. Pass every ref that needs the node to `useRender`'s `ref` instead of trying to merge them through props. --- ## The Merge Rules, Precisely Both shapes clone an element and merge props onto it. What differs is the ordering language and the escape hatch, and confusing the two is the most common source of "my handler runs but nothing happens". | Concern | Slot-based composition | `mergeProps` composition | | --------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | **Handler order** | The child's (consumer's) handler runs first, then the slot's | Handlers run right-to-left -- rightmost argument first | | **Suppressing internal behavior** | Consumer calls `event.preventDefault()`; the composed handler checks `defaultPrevented` | Consumer calls `event.preventBaseUIHandler()` -- no `preventDefault`, no `stopPropagation` | | **`className`** | Concatenated | Concatenated right-to-left | | **`style`** | Shallow-merged, child's keys win | Shallow-merged, rightmost keys win | | **Other props** | Child's value wins | Rightmost value wins | | **`ref`** | Composed onto the child | Not merged -- pass refs to `useRender`'s `ref` parameter | In practice both put the consumer first when the component passes consumer props last -- `mergeProps(internalProps, consumerProps)` -- which is the convention to follow. Two facts do not generalize between them: - `preventBaseUIHandler()` exists only on React synthetic events. Where the library binds a native listener instead, it has no effect and the internal handler runs regardless. - `preventDefault()` suppresses a Slot-composed internal handler only where the primitive opted into the `defaultPrevented` check; some primitives deliberately compose without it so their behavior cannot be disabled that way. --- ## Composition Helpers, Dependency-Free Any component can implement substitution without a library. These are the two pieces that make it safe. ### `composeEventHandlers` ```tsx interface ComposeEventHandlersOptions { /** When true, the internal handler is skipped if the consumer called preventDefault(). */ readonly checkForDefaultPrevented?: boolean; } function composeEventHandlers<TEvent extends { defaultPrevented: boolean }>( consumerHandler: ((event: TEvent) => void) | undefined, internalHandler: (event: TEvent) => void, { checkForDefaultPrevented = true }: ComposeEventHandlersOptions = {}, ) { return function handleEvent(event: TEvent) { consumerHandler?.(event); if (checkForDefaultPrevented && event.defaultPrevented) return; internalHandler(event); }; } ``` **Why good:** The consumer's handler always runs, and `preventDefault()` gives them a documented way to opt out of the component's behavior for a single event. Opting a specific handler out of the check (`checkForDefaultPrevented: false`) is a deliberate statement that the behavior is not suppressible -- use it for things like closing on selection, where suppression would leave the component in a broken state. **Why replacement is bad:** ```tsx // BAD: whichever is written second wins; the other silently disappears <button onClick={props.onClick} /> // component behavior deleted <button onClick={handleOpen} /> // consumer behavior deleted ``` ### `composeRefs` ```tsx type PossibleRef<T> = React.Ref<T> | undefined; function setRef<T>( ref: PossibleRef<T>, value: T | null, ): (() => void) | undefined { if (typeof ref === "function") { // React 19 callback refs may return a cleanup function; older ones return void. return ref(value) as (() => void) | undefined; } if (ref !== null && ref !== undefined) { // RefObject.current is typed readonly; assigning is the documented ref-object contract. (ref as React.MutableRefObject<T | null>).current = value; } return undefined; } function composeRefs<T>(...refs: readonly PossibleRef<T>[]) { return (node: T) => { const cleanups = refs.map((ref) => setRef(ref, node)); return () => { cleanups.forEach((cleanup, index) => { if (typeof cleanup === "function") cleanup(); else setRef(refs[index], null); }); }; }; } ``` **Why good:** Every consumer of the node gets it -- the consumer's forwarded ref, the component's internal measurement ref, and a positioning library's ref, all at once. The returned cleanup is what React 19 expects from a callback ref; returning it means a ref that registers something on mount can unregister it on unmount, instead of leaking the detached node. **Gotcha:** React 19 allows callback refs to return a cleanup function, and when one does, React stops calling the ref with `null` on unmount. A `composeRefs` that ignores return values therefore leaks the old node for any ref that adopted the new convention. Collect the returns, and fall back to `null` for refs that returned nothing. --- ## Forwarding Discipline: Prop Ordering Substitution is only half of the contract. The part must also let everything the consumer passes reach the DOM. ```tsx const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>( function AccordionTrigger({ asChild = false, ...rest }, forwardedRef) { const { open, panelId, triggerId, toggle } = useAccordionItemContext("Trigger"); const Component = asChild ? Slot.Root : "button"; return ( <Component // 1. Defaults -- before the spread so the consumer can override them type={asChild ? undefined : "button"} // 2. The consumer's props {...rest} // 3. Non-negotiables -- after the spread so nothing can clobber them ref={forwardedRef} id={rest.id ?? triggerId} aria-expanded={open} aria-controls={panelId} data-state={open ? "open" : "closed"} onClick={composeEventHandlers(rest.onClick, toggle)} /> ); }, ); ``` **Why good:** The ordering is the policy. A consumer can pass `type="submit"` and win, because defaults precede the spread. A consumer cannot accidentally pass `aria-expanded` and desynchronize it from the real state, because the wiring follows the spread. Their `onClick` is not overwritten -- it is composed, and it runs first. `id` is the one non-negotiable with an opt-out, because external `aria-controls` sometimes has to name it. **Why the reverse order is bad:** ```tsx // BAD: the spread comes last <button aria-expanded={open} onClick={toggle} {...rest} /> ``` A consumer passing `onClick` for analytics silently deletes the toggle. A consumer passing `aria-expanded` -- often copied from an example -- freezes the announced state. Neither produces a type error. **Why an allowlist is bad:** ```tsx // BAD: only the props the author imagined function AccordionTrigger({ children, className, onClick, }: AccordionTriggerProps) { return ( <button className={className} onClick={onClick}> {children} </button> ); } ``` `id`, `aria-label`, `data-testid`, `tabIndex`, `onKeyDown`, `style` and the ref all vanish. Consumers respond by adding a wrapper element to hang those on, which breaks the layout and the selectors the trigger sat in. --- ## Ref Forwarding Across React Versions ```tsx // Works everywhere const Item = forwardRef<HTMLDivElement, ItemProps>( function Item(props, forwardedRef) { return <div {...props} ref={forwardedRef} />; }, ); // React 19: ref is an ordinary prop, no wrapper needed function Item({ ref, ...props }: ItemProps & { ref?: React.Ref<HTMLDivElement> }) { return <div {...props} ref={ref} />; } ``` **Why it matters either way:** the rule is not "call `forwardRef`", it is "the ref reaches the DOM node". Pick one form per library so the parts look alike, and never accept a ref you do not attach -- an accepted-and-dropped ref is worse than a missing one, because `ref` type-checks at the call site and the consumer has no reason to suspect it. --- ## Anti-Pattern: Hand-Rolled `cloneElement` ```tsx // BAD: looks like Slot, behaves nothing like it function Trigger({ children, ...props }: TriggerProps) { return React.cloneElement(children as React.ReactElement, props); } ``` **Why bad:** `cloneElement` overwrites rather than merges. The child's `onClick` is replaced by the trigger's, its `className` is replaced instead of concatenated, its `style` is replaced instead of merged, and its `ref` is dropped entirely unless it is passed through explicitly. Nothing errors. The component renders, and the consumer's props are gone. If substitution is needed and no library is available, implement the merge explicitly with `composeEventHandlers` and `composeRefs` -- the two helpers above are the whole difference. -
state-contract.md 11.4 KB
# Composable Components - State Contract > The controlled/uncontrolled hook, the change-details object with reason and cancelation, and publishing state to the DOM as `data-*` attributes. See [SKILL.md](../SKILL.md) for decision frameworks and [core.md](core.md) for the compound parts these hook into. **Prerequisites**: Understand compound parts and context scoping from [core.md](core.md) first -- the state contract is what the `Root` publishes through that context. --- ## The Triple Every piece of state a component owns is exposed three ways at once: | Prop | Role | Present when | | --------------- | -------------------------- | ---------------------------------------- | | `value` | Controlled value | The consumer owns the state | | `defaultValue` | Uncontrolled initial value | The component owns the state (read once) | | `onValueChange` | Change notification | Always -- fires in both modes | The names follow the state: `open` / `defaultOpen` / `onOpenChange`, `checked` / `defaultChecked` / `onCheckedChange`, `value` / `defaultValue` / `onValueChange`. The mode is decided by whether the controlled prop is `undefined`, not by a separate `controlled` flag. --- ## `useControllableState` ```tsx import { useCallback, useEffect, useRef, useState } from "react"; interface ChangeEventDetails<TReason extends string> { /** Why the change happened -- lets consumers run side effects conditionally. */ readonly reason: TReason; /** The DOM event that caused the change, when there was one. */ readonly event: Event | undefined; /** Stops the component from applying the change. */ cancel: () => void; readonly isCanceled: boolean; } interface UseControllableStateParams<TValue, TReason extends string> { readonly value: TValue | undefined; readonly defaultValue: TValue; readonly onChange?: ( value: TValue, details: ChangeEventDetails<TReason>, ) => void; readonly componentName: string; } type SetControllableState<TValue, TReason extends string> = ( value: TValue, details: ChangeEventDetails<TReason>, ) => void; function useControllableState<TValue, TReason extends string>({ value, defaultValue, onChange, componentName, }: UseControllableStateParams<TValue, TReason>): readonly [ TValue, SetControllableState<TValue, TReason>, ] { const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); const isControlled = value !== undefined; const resolvedValue = isControlled ? value : uncontrolledValue; useModeChangeWarning(isControlled, componentName); const onChangeRef = useLatest(onChange); const setValue = useCallback<SetControllableState<TValue, TReason>>( (nextValue, details) => { onChangeRef.current?.(nextValue, details); if (details.isCanceled) return; if (!isControlled) setUncontrolledValue(nextValue); }, [isControlled, onChangeRef], ); return [resolvedValue, setValue] as const; } ``` **Why good:** One resolved value is returned, so no call site ever has to ask which mode it is in. `onChange` fires before the internal update and in both modes, so consumer side effects are mode-independent. When controlled, the internal state is never written, so there is no second copy to drift. The callback is read from a ref, so a consumer passing an inline arrow function does not invalidate `setValue` on every render. ### The Mode-Change Warning ```tsx function useModeChangeWarning( isControlled: boolean, componentName: string, ): void { const initialModeRef = useRef(isControlled); useEffect(() => { if (initialModeRef.current === isControlled) return; const from = initialModeRef.current ? "controlled" : "uncontrolled"; const to = isControlled ? "controlled" : "uncontrolled"; console.error( `${componentName} is changing from ${from} to ${to}. ` + `Decide the mode for the lifetime of the component: either always pass a value, or never pass one.`, ); }, [isControlled, componentName]); } ``` **Why good:** Mode switching is a real bug with a confusing symptom -- the value appears to freeze or to jump backwards -- and it usually comes from a `value={data?.selected}` that is `undefined` on the first render. The warning names the transition and the fix. Keep the check out of render so it does not fire twice under StrictMode. ### The `useLatest` Helper ```tsx function useLatest<T>(value: T): React.RefObject<T> { const ref = useRef(value); useEffect(() => { ref.current = value; }); return ref; } ``` --- ## Building and Consuming Change Details ```tsx type DialogChangeReason = | "trigger-press" | "close-press" | "backdrop-press" | "escape-key" | "outside-press"; function createChangeEventDetails<TReason extends string>( reason: TReason, event?: Event, ): ChangeEventDetails<TReason> { let isCanceled = false; return { reason, event, cancel() { isCanceled = true; }, get isCanceled() { return isCanceled; }, }; } ``` The closure variable behind a getter is deliberate: a plain `isCanceled: false` field that `cancel()` reassigns on `this` breaks the moment a consumer destructures (`const { cancel } = details`), which is exactly how it will be used. Inside the component, every state change names its cause: ```tsx const handleBackdropPress = (event: React.MouseEvent) => { setOpen(false, createChangeEventDetails("backdrop-press", event.nativeEvent)); }; ``` And the consumer can branch on it, or refuse it, without hoisting the state: ```tsx <Dialog.Root onOpenChange={(open, details) => { if (!open && details.reason === "outside-press" && form.isDirty) { details.cancel(); setShowDiscardPrompt(true); return; } if (!open) analytics.track("dialog_dismissed", { reason: details.reason }); }} > ``` **Why good:** The consumer keeps the component uncontrolled -- no `open` prop, no `useState` -- and still vetoes a specific dismissal path. Without `cancel()`, the only way to block one dismissal reason is to take full control of the state and reimplement every path that should still work. **Why a bare callback is bad:** ```tsx // BAD: no reason, no veto onOpenChange?: (open: boolean) => void; ``` The consumer cannot tell a submit-close from an Escape-close, so "save on close" also fires when the user backs out. The only escape is controlled mode plus a reimplementation of dismissal, which is exactly the accessibility work the component existed to own. ### Cancelation Is Not Always Appropriate `cancel()` fits changes the user initiated and a consumer might reasonably block: dismissal with unsaved work, selecting a locked row, a tab switch mid-validation. It does not fit changes that reflect reality rather than intent -- a positioner reporting `data-side` after measurement, or a controlled value echoing back. Ship it where a veto is meaningful; a `cancel()` the component ignores is worse than none. --- ## State as `data-*` Attributes ### Enumerated States Are Values, Boolean States Are Presence ```tsx function toStateAttributes(state: TriggerState): Record<string, string> { return { "data-state": state.open ? "open" : "closed", "data-side": state.side, ...(state.disabled ? { "data-disabled": "" } : null), ...(state.pressed ? { "data-pressed": "" } : null), }; } ``` ```css /* The consumer's stylesheet -- the component knows none of this */ [data-state="open"] { opacity: 1; } [data-state="closed"] { opacity: 0; } [data-disabled] { pointer-events: none; } [data-side="top"] { --arrow-rotation: 180deg; } ``` **Why good:** Visual state lives entirely in CSS. Opening a popup does not re-render the consumer's tree, because nothing had to travel back out through props to reach a class name. ```tsx // BAD: boolean written as a value <button data-disabled={disabled} data-open={open} /> ``` **Why bad:** React renders `data-disabled="false"` for `false` -- the attribute is present, so `[data-disabled]` matches and every disabled style applies to enabled elements. (React omits the attribute for `undefined` and `null`, not for `false`, on `data-*` and `aria-*` attributes.) Either spread the attribute conditionally, or write `data-disabled={disabled ? "" : undefined}`. ### Two Valid Vocabularies | Shape | Reads as | Trade-off | | ------------------------------------------------ | --------------------------------- | ------------------------------------------------- | | `data-state="open" \| "closed"` | One attribute, mutually exclusive | One selector per state; easy to enumerate | | `data-open` / `data-closed` as separate presence | One attribute per state | Simpler selectors; more attributes on the element | Both are current in shipping libraries. Pick one per library and never mix them -- a consumer who learns `[data-state="open"]` on one part and needs `[data-open]` on the next has to read the source of every part. ### State-Driven `className` and `style` Exposing the state object to the styling props removes the last reason to lift state out: ```tsx <Switch.Thumb className={(state) => (state.checked ? "thumb thumb--on" : "thumb")} /> <Switch.Thumb style={(state) => ({ transform: `translateX(${state.checked ? "100%" : "0"})` })} /> ``` **Why good:** A consumer whose styling tool cannot express attribute selectors still gets the state, without the component publishing a `renderThumb` prop or the consumer mirroring `checked` in their own state. Support the function form _in addition to_ the data attributes, never instead of them -- the attributes are what make pure-CSS styling possible. ### Entry and Exit Animation An exit animation needs the element to stay mounted after the state says "closed". Publish that as its own attributes rather than as a `isExiting` prop: ```tsx // Mounted, closing: data-state="closed" plus an explicit ending-style marker <div data-state="closed" data-ending-style="" /> ``` **Why good:** The consumer writes the transition in CSS and the component unmounts when the transition ends. A `transitionDuration` prop, by contrast, duplicates a number that already exists in the stylesheet and drifts from it. **Gotcha:** attributes written after measurement -- `data-side`, `data-align` -- can differ between first paint and the layout effect that positions the element. Drive entry animations off the explicit starting-style attribute, not off mount, or the animation plays from the wrong side. --- ## Alignment: Retrofitting the Triple An existing component with `isOpen` + `onClose` becomes compliant in three steps, without breaking call sites in the same commit: ```tsx // Step 1 -- add the missing pieces, keep the old ones working interface DialogRootProps { readonly open?: boolean; readonly defaultOpen?: boolean; readonly onOpenChange?: ( open: boolean, details: ChangeEventDetails<DialogChangeReason>, ) => void; } // Step 2 -- route every internal close through setOpen(false, details), // so `onClose` becomes a thin adapter over onOpenChange // Step 3 -- migrate call sites, then delete the adapter ``` **Why this order:** step 2 is where the behavior actually changes -- once every dismissal path produces a reason, the component is correct even for consumers still on the old props. Deleting the old props first forces every call site into one commit and hides the behavioral change inside a mechanical rename.
-
-
reference.md 11.4 KB
# Composable Components Quick Reference > Lookup tables for translating configuration props into parts, naming parts and state, and wiring roles. See [SKILL.md](SKILL.md) for the patterns, the red flags and the alignment checklist, and [examples/](examples/) for full implementations. --- ## Configuration Prop to Composable Equivalent | Configuration prop | Composable equivalent | | ------------------------------- | ------------------------------------------------------- | | `title="..."` | `<Root.Title>` part | | `description="..."` | `<Root.Description>` part | | `showCloseButton` | Render a `<Root.Close>` part, or don't | | `closeButtonLabel="..."` | The consumer's `aria-label` on their own `<Root.Close>` | | `hideBackdrop` | Render a `<Root.Backdrop>` part, or don't | | `icon={<Icon />}` | Children of the part that should contain it | | `renderFooter={(close) => ...}` | Children, with `<Root.Close>` reading context | | `renderItem={(item) => ...}` | `<Root.Item>` with arbitrary children | | `items={[...]}` | `<Root.Item>` children registering with the `Root` | | `groupBy="category"` | `<Root.Group>` + `<Root.GroupLabel>` markup | | `emptyMessage="..."` | The consumer's conditional JSX inside the popup | | `size` / `padding` / `bgColor` | The consumer's `className` on the part | | `as="a"` / `component={Link}` | `asChild` + Slot, or the `render` prop | | `isOpen` + `onClose` | `open` + `defaultOpen` + `onOpenChange(open, details)` | | `disabled` / `modal` / `loop` | Stay props -- they change behavior, not markup | --- ## State Triple Naming | State | Controlled | Uncontrolled | Change callback | | --------- | ---------- | ----------------- | ------------------------------------- | | Open | `open` | `defaultOpen` | `onOpenChange(open, details)` | | Checked | `checked` | `defaultChecked` | `onCheckedChange(checked, details)` | | Pressed | `pressed` | `defaultPressed` | `onPressedChange(pressed, details)` | | Value | `value` | `defaultValue` | `onValueChange(value, details)` | | Selection | `selected` | `defaultSelected` | `onSelectedChange(selected, details)` | | Expansion | `expanded` | `defaultExpanded` | `onExpandedChange(expanded, details)` | Rules: the mode is decided by `value !== undefined`, never by a `controlled` flag. `defaultX` is read once at mount. The callback fires in both modes. --- ## Change Details Object | Field | Type | Purpose | | ---------------------- | ------------ | ------------------------------------------------------- | | `reason` | string union | Why the change happened -- branch side effects on it | | `event` | `Event` | The originating DOM event, when there was one | | `cancel()` | method | Refuse the change without taking control of the state | | `isCanceled` | boolean | Whether `cancel()` was called | | `allowPropagation()` | method | Re-allow a DOM event the component would otherwise stop | | `isPropagationAllowed` | boolean | Whether propagation was re-allowed | --- ## Polymorphism API Surface | Concern | Slot-based | `render`-based | | ------------------------- | ------------------------------------------------- | --------------------------------------------------------- | | Consumer-facing prop | `asChild?: boolean` | `render?: ReactElement \| (props, state) => ReactElement` | | Import | `import { Slot } from "radix-ui"` | `import { useRender } from "@base-ui/react/use-render"` | | Element to render | `asChild ? Slot.Root : "button"` | `useRender({ defaultTagName: "button", render, props })` | | Prop merging helper | hand-composed, or the primitive's own composition | `import { mergeProps } from "@base-ui/react/merge-props"` | | Multiple children | `Slot.Slottable` | Place `props` yourself in the callback form | | Typed slot | `Slot.createSlot<TElement, TProps>(name)` | `useRender.ComponentProps<"button">` | | Ref composition | Composed onto the child by the slot | `useRender`'s `ref` parameter accepts an array of refs | | Suppress internal handler | `event.preventDefault()` in the consumer handler | `event.preventBaseUIHandler()` in the consumer handler | | State-driven class/style | Attribute selectors in CSS | `className` / `style` also accept `(state) => ...` | `mergeProps` takes up to five prop objects; use `mergePropsN(propsArray)` beyond that. It does not merge refs -- only the rightmost survives. --- ## Part Naming Conventions | Part | Responsibility | | ------------- | ----------------------------------------------------------------- | | `Root` | Owns state and context; renders no DOM element in most primitives | | `Trigger` | Opens or toggles; carries `aria-expanded` / `aria-haspopup` | | `Portal` | Moves subsequent parts out of the DOM flow | | `Backdrop` | The dismissible surface behind a modal layer | | `Positioner` | Owns computed coordinates; publishes `data-side` / `data-align` | | `Popup` | The panel itself; carries the role and the labelling attributes | | `Arrow` | Positioned pointer, rotated from `data-side` | | `Title` | Registers the id consumed by `aria-labelledby` | | `Description` | Registers the id consumed by `aria-describedby` | | `Close` | Any element that dismisses; readable anywhere inside the `Root` | | `Item` | A registered, navigable entry | | `ItemText` | Marks an item's label for typeahead and value display | | `Indicator` | Renders only in a specific state (checked, selected) | | `Group` | Groups items; carries `role="group"` | | `GroupLabel` | Labels a group via a registered id | | `Value` | Displays the current value, with a `placeholder` when empty | Export them namespaced (`Dialog.Trigger`) so a part's `Root` is unambiguous at every call site. --- ## State Attribute Vocabulary | Attribute | Kind | Meaning | | ----------------------------------------- | -------- | --------------------------------------------------- | | `data-state="open" \| "closed"` | value | Disclosure state (one-attribute vocabulary) | | `data-open` / `data-closed` | presence | Disclosure state (presence vocabulary) | | `data-popup-open` | presence | On a trigger, when its popup is open | | `data-pressed` | presence | Pointer is currently down on the element | | `data-highlighted` | presence | Item is the active one for keyboard navigation | | `data-selected` / `data-checked` | presence | Selection state | | `data-disabled` | presence | Non-interactive -- never write `="false"` | | `data-side="top" \| "right" \| ...` | value | Which side a popup was placed on, after measurement | | `data-align="start" \| "center" \| "end"` | value | Alignment along that side | | `data-orientation` | value | Composite widget axis | | `data-starting-style` | presence | Present for the first frame, for entry animations | | `data-ending-style` | presence | Present while exiting, before unmount | Enumerated states are attribute values; boolean states are attribute presence. --- ## ARIA Wiring by Widget | Widget | Trigger carries | Panel/list carries | | --------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Dialog | `aria-haspopup="dialog"`, `aria-expanded` | `role="dialog"`, `aria-modal`, `aria-labelledby`, `aria-describedby` | | Popover | `aria-expanded`, `aria-controls` | `role="dialog"` when it contains focusables | | Tooltip | `aria-describedby` while open | `role="tooltip"` | | Menu | `aria-haspopup="menu"`, `aria-expanded` | `role="menu"`, items `role="menuitem"` | | Listbox | `aria-haspopup="listbox"`, `aria-expanded` | `role="listbox"`, items `role="option"` + `aria-selected` | | Combobox | `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-activedescendant` | `role="listbox"` | | Accordion | `aria-expanded`, `aria-controls` | `role="region"`, `aria-labelledby` pointing at the trigger | | Tabs | `role="tab"`, `aria-selected`, `aria-controls` | `role="tabpanel"`, `aria-labelledby` | --- ## Keyboard Contract by Widget | Widget | Keys the component must own | | ---------------- | ------------------------------------------------------------------------- | | Dialog (modal) | `Escape` closes; `Tab`/`Shift+Tab` trapped | | Popover | `Escape` closes; focus returns to the trigger | | Menu | `ArrowUp`/`ArrowDown`, `Home`/`End`, typeahead, `Escape`, `Enter`/`Space` | | Listbox | Arrows, `Home`/`End`, typeahead, `Enter` selects, `Escape` closes | | Combobox | Arrows move `aria-activedescendant`; input keeps focus | | Tabs (automatic) | Arrows move and activate; `Home`/`End` jump | | Tabs (manual) | Arrows move focus only; `Enter`/`Space` activates | | Accordion | Arrows move between triggers; `Home`/`End` jump | | Slider | Arrows step; `PageUp`/`PageDown` step large; `Home`/`End` to bounds | Call `preventDefault()` only for keys actually handled -- unhandled keys must reach the consumer and the browser. -
SKILL.md 33.2 KB
--- name: meta-design-composable-components description: Composable component APIs — parts, state, polymorphism --- # Composable Components > **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. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **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)** </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 **When to use:** - 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 **When NOT to use:** - 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 **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 --- ## Detailed Resources - [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 --- <philosophy> ## Philosophy A composable component draws one line and never crosses it: > **The component owns behavior, state and accessibility. The consumer owns markup, element type and styling.** 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. 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. **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: | 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 | 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. **When to apply this skill:** - 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 **When NOT to apply:** - 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 </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Compound Components 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. ```tsx // Monolith: every new layout need becomes a new prop <Dialog title="Delete" description="Permanent." showCloseButton size="lg" renderFooter={renderActions} /> // Compound: layout is JSX, the component still owns behavior <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.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). --- ### Pattern 2: Controlled/Uncontrolled Duality 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. ```tsx const [open, setOpen] = useControllableState({ value: props.open, defaultValue: props.defaultOpen ?? false, onChange: props.onOpenChange, }); ``` **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. **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. **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: ```tsx onOpenChange={(open, details) => { if (details.reason === "outside-press" && hasUnsavedEdits) details.cancel(); }} ``` **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. > **Full `useControllableState` implementation and details object:** See [examples/state-contract.md](examples/state-contract.md). --- ### Pattern 3: Polymorphism -- `asChild` and `render` 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. ```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), }); ``` **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). --- ### Pattern 4: State as `data-*` Attributes, Zero Visual Opinions 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. ```tsx <button data-state={open ? "open" : "closed"} data-side={side} {...(disabled ? { "data-disabled": "" } : null)} /> ``` **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. ```tsx // Bad: a boolean state written as a value <button data-disabled={disabled} /> // renders data-disabled="false" ``` **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. **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. > **Full attribute vocabulary, state-driven `className`/`style` functions, and exit-animation attributes:** See [examples/state-contract.md](examples/state-contract.md). --- ### Pattern 5: Prop Forwarding Discipline 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. ```tsx <button type="button" // default: overridable {...rest} // consumer's props ref={composeRefs(forwardedRef, localRef)} // non-negotiable aria-expanded={open} data-state={open ? "open" : "closed"} onClick={composeEventHandlers(rest.onClick, handleClick)} /> ``` **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). --- ### Pattern 7: 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. ```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(); 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). </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. **Medium Priority Issues:** - 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. **Common Mistakes:** - 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. **Gotchas & Edge Cases:** - **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. </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>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.