component-api — one dialect, ten components
Stage: Phase 3 — Foundation (after tokens, before the component tier) - Reads: design/SYSTEM.md, lib/utils.ts (cn) - Writes: no file of its own — the shared prop contract every components/ui/* and section component honors (a reference skill, like taste)
Standard
A site reads as assembled when each component is individually fine but they speak ten prop dialects — variant="primary" here, type="filled" there, isLarge next to size="lg", a bespoke as="a" beside a real asChild. Close inspection catches the seam even when no single component is wrong. First-grade means the opposite: every component is configured the same way, so a developer who has used one has used all of them, and the system reads as one author's work. This is a design-system discipline orthogonal to any component's visual spec — ultraweb:buttons owns what a button looks like; this skill owns how you configure it. It governs; it ships no file. Every component-tier skill obeys it, and ultraweb:gate-code greps for its violations.
The contract
Eight rules. A component may use only the axes it needs, but the ones it uses obey these names and shapes.
The vocabulary is exactly three axes: variant, size, tone. variant = the form/weight the component takes (button: primary/secondary/ghost; card: media/stat/editorial). size = the scale step, shared meaning site-wide (sm/md/lg/xl). tone = semantic intent that selects a token family (default → foreground/muted, brand → primary, danger → destructive, success/warning → status tokens). No synonyms, ever — never type, kind, color, appearance, emphasis, intent, or scale. Status-bearing components (badge, alert, toast, input error) express intent through tone; a component whose destruction is a distinct filled treatment (the Button) may legitimately carry destructive as a variant — but the name of every axis is fixed.
Variants are cva() enums that resolve to tokens, never literals. Each variant/size/tone value is a row in one cva() map whose classes are token-backed utilities (bg-primary, text-sm, rounded-md, shadow-md) with a defaultVariants for the rest state. A hex, a bare oklch(, or an arbitrary bg-[…] inside a variant row is a defect — the value you reached for is a token ultraweb:tokens already owns. This is where "variants map to the design system" is enforced, not merely intended.
cn() is the only class merge, and className comes last. cn (clsx + tailwind-merge, from lib/utils.ts) is the single merge path: cn(buttonVariants({ variant, size }), className). The consumer's className is the final argument so tailwind-merge lets a caller override a token utility (rounded-none, w-full) without specificity wars or !important. Never string-concatenate classes with + or template literals; never put className before the variants.
Polymorphism is asChild (Radix Slot) — never a custom as prop. When a component must render as a different element — a CTA that is a next/link, a card that is one big link — it accepts asChild and renders <Slot>, which merges its classes and props onto the caller's single child. No as="a" string, no component={Link} prop: those discard the child element's real types and its native props.
Every root carries data-slot="<component>[-<part>]"; target parts by data-slot. The shadcn/React-19 hook for styling, state, and tests. Group styling and state variants select data-[slot=…], group-data-[state=open], has-[[data-slot=icon]] — never fragile child combinators (> div > span).
Refs are plain props (React 19) — no forwardRef; spread ...props onto the root. function Button({ ref, className, ...props }). Spreading ...props last-but-one (before className is applied via cn) forwards every native attribute — type, disabled, onClick, id, name — and every aria-*/role for free. Native-attribute forwarding and a11y pass-through are the same rule: don't enumerate props you could spread.
Stateful components support controlled and uncontrolled, one pattern. Anything with internal state (tabs, accordion, dialog, switch) accepts value/defaultValue (or open/defaultOpen) + onValueChange; controlled when the value prop is present, uncontrolled otherwise. This is Radix's own contract — restyle the Radix primitive, never re-implement its state machine as a third mode.
Compound components share state via context; sub-parts are X.Header/X.Body. A multi-part component (Card, Field) exports a namespace where each part carries its own data-slot and shared config flows through a tiny context — not prop-drilling, not one giant props object. Callers compose the parts; the component owns the wiring.
Three composition shapes, in order of reach-for: the variant enum (rule 2) is the default closed set and covers ~90% of configuration; asChild/Slot (rule 4) handles element swaps; a render-prop escape hatch (children as a function receiving state — Base-UI style) is the last resort, only when a caller must inject arbitrary markup a closed variant can't express. Reach left before right.
Anti-patterns
as="a" / component={…} string-polymorphism props — use asChild + Slot (rule 4).
type=, kind=, appearance=, color=, emphasis=, intent=, scale= as prop names — synonyms of the canonical trio; rename to variant/size/tone.
- Boolean soup —
isPrimary, isLarge, outlined, filled, danger booleans instead of enum axes. Two booleans that can't both be true are one variant.
forwardRef( in a new component file — React 19 passes ref as a prop; grep for it, treat a hit as a relic.
className merged with + or a template literal, or placed before the variants in cn() — the caller can no longer override.
- A hex, bare
oklch(, or bg-[…]/text-[…] inside a cva() variant row — defer to a token (ultraweb:tokens).
- Child-combinator selectors (
.card > div) instead of data-[slot=…] targeting.
- Re-implementing a Radix stateful primitive's controlled/uncontrolled logic by hand instead of restyling it.
- Per-call-site
className soup faking a variant that should live in the shared cva() map — the single loudest "assembled, not authored" tell.
Worked example — Tidepool, port-logistics SaaS (Precision Instrument — Neo-grotesque Minimal)
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: component-api3description: The cross-cutting prop contract every component skill speaks — one shared dialect so ten independently-built components read as authored, not assembled. Fixes the canonical prop vocabulary (variant/size/tone), cva()-based variants that resolve to tokens never literals, asChild polymorphism via Radix Slot (never a custom `as` prop), the cn()/tailwind-merge order that lets a caller's className win, data-slot part targeting and ref-as-prop forwarding for React 19, controlled/uncontrolled parity, and native-attribute + a11y pass-through. Invoke in the Foundation phase after tokens and before the component tier, whenever building or restyling any component (buttons, cards, forms, pricing, data-display, hero…), deciding a prop name, adding a variant, or when the user says "the props are inconsistent", "name this prop", "should this be asChild", "why do the components feel assembled", or a component invents its own variant vocabulary.4---56# component-api — one dialect, ten components78**Stage:** Phase 3 — Foundation (after tokens, before the component tier) - **Reads:** design/SYSTEM.md, lib/utils.ts (cn) - **Writes:** no file of its own — the shared prop contract every `components/ui/*` and section component honors (a reference skill, like taste)910## Standard1112A site reads as *assembled* when each component is individually fine but they speak ten prop dialects — `variant="primary"` here, `type="filled"` there, `isLarge` next to `size="lg"`, a bespoke `as="a"` beside a real `asChild`. Close inspection catches the seam even when no single component is wrong. First-grade means the opposite: every component is configured the same way, so a developer who has used one has used all of them, and the system reads as one author's work. This is a design-system discipline orthogonal to any component's visual spec — `ultraweb:buttons` owns what a button *looks* like; this skill owns how you *configure* it. It governs; it ships no file. Every component-tier skill obeys it, and `ultraweb:gate-code` greps for its violations.1314## The contract1516Eight rules. A component may use only the axes it needs, but the ones it uses obey these names and shapes.17181. **The vocabulary is exactly three axes: `variant`, `size`, `tone`.** `variant` = the form/weight the component takes (button: primary/secondary/ghost; card: media/stat/editorial). `size` = the scale step, shared meaning site-wide (`sm`/`md`/`lg`/`xl`). `tone` = semantic intent that selects a token *family* (`default` → foreground/muted, `brand` → primary, `danger` → destructive, `success`/`warning` → status tokens). No synonyms, ever — never `type`, `kind`, `color`, `appearance`, `emphasis`, `intent`, or `scale`. Status-bearing components (badge, alert, toast, input error) express intent through `tone`; a component whose destruction is a *distinct filled treatment* (the Button) may legitimately carry `destructive` as a `variant` — but the name of every axis is fixed.19202. **Variants are `cva()` enums that resolve to tokens, never literals.** Each `variant`/`size`/`tone` value is a row in one `cva()` map whose classes are token-backed utilities (`bg-primary`, `text-sm`, `rounded-md`, `shadow-md`) with a `defaultVariants` for the rest state. A hex, a bare `oklch(`, or an arbitrary `bg-[…]` inside a variant row is a defect — the value you reached for is a token `ultraweb:tokens` already owns. This is where "variants map to the design system" is *enforced*, not merely intended.21223. **`cn()` is the only class merge, and `className` comes last.** `cn` (clsx + tailwind-merge, from `lib/utils.ts`) is the single merge path: `cn(buttonVariants({ variant, size }), className)`. The consumer's `className` is the *final* argument so tailwind-merge lets a caller override a token utility (`rounded-none`, `w-full`) without specificity wars or `!important`. Never string-concatenate classes with `+` or template literals; never put `className` before the variants.23244. **Polymorphism is `asChild` (Radix `Slot`) — never a custom `as` prop.** When a component must render as a different element — a CTA that is a `next/link`, a card that is one big link — it accepts `asChild` and renders `<Slot>`, which merges its classes and props onto the caller's single child. No `as="a"` string, no `component={Link}` prop: those discard the child element's real types and its native props.25265. **Every root carries `data-slot="<component>[-<part>]"`; target parts by data-slot.** The shadcn/React-19 hook for styling, state, and tests. Group styling and state variants select `data-[slot=…]`, `group-data-[state=open]`, `has-[[data-slot=icon]]` — never fragile child combinators (`> div > span`).27286. **Refs are plain props (React 19) — no `forwardRef`; spread `...props` onto the root.** `function Button({ ref, className, ...props })`. Spreading `...props` last-but-one (before `className` is applied via `cn`) forwards every native attribute — `type`, `disabled`, `onClick`, `id`, `name` — *and* every `aria-*`/`role` for free. Native-attribute forwarding and a11y pass-through are the same rule: don't enumerate props you could spread.29307. **Stateful components support controlled *and* uncontrolled, one pattern.** Anything with internal state (tabs, accordion, dialog, switch) accepts `value`/`defaultValue` (or `open`/`defaultOpen`) + `onValueChange`; controlled when the value prop is present, uncontrolled otherwise. This is Radix's own contract — restyle the Radix primitive, never re-implement its state machine as a third mode.31328. **Compound components share state via context; sub-parts are `X.Header`/`X.Body`.** A multi-part component (Card, Field) exports a namespace where each part carries its own `data-slot` and shared config flows through a tiny context — not prop-drilling, not one giant props object. Callers compose the parts; the component owns the wiring.3334**Three composition shapes, in order of reach-for:** the **variant enum** (rule 2) is the default closed set and covers ~90% of configuration; **`asChild`/`Slot`** (rule 4) handles element swaps; a **render-prop escape hatch** (`children` as a function receiving state — Base-UI style) is the last resort, only when a caller must inject arbitrary markup a closed variant can't express. Reach left before right.3536## Anti-patterns3738- `as="a"` / `component={…}` string-polymorphism props — use `asChild` + `Slot` (rule 4).39- `type=`, `kind=`, `appearance=`, `color=`, `emphasis=`, `intent=`, `scale=` as prop names — synonyms of the canonical trio; rename to `variant`/`size`/`tone`.40- Boolean soup — `isPrimary`, `isLarge`, `outlined`, `filled`, `danger` booleans instead of enum axes. Two booleans that can't both be true are one `variant`.41- `forwardRef(` in a new component file — React 19 passes `ref` as a prop; grep for it, treat a hit as a relic.42- `className` merged with `+` or a template literal, or placed *before* the variants in `cn()` — the caller can no longer override.43- A hex, bare `oklch(`, or `bg-[…]`/`text-[…]` inside a `cva()` variant row — defer to a token (`ultraweb:tokens`).44- Child-combinator selectors (`.card > div`) instead of `data-[slot=…]` targeting.45- Re-implementing a Radix stateful primitive's controlled/uncontrolled logic by hand instead of restyling it.46- Per-call-site `className` soup faking a variant that should live in the shared `cva()` map — the single loudest "assembled, not authored" tell.4748## Worked example — Tidepool, port-logistics SaaS (Precision Instrument — Neo-grotesque Minimal)4950Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.5152## Composes with5354Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.