Scaffold a new design system component with all required files and registration steps. Use when asked to add, create, or scaffold a new design system component.
Installs into .claude/skills of the current project.
Sponsors
SSkills DirectorySkills Directory
Know which skills are safe — weekly.
SSkills DirectorySkills Directory
Is that skill safe to install?
Are you the author of New Component?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/robritacca-dotcom-new-component)
---
name: new-component
description: Scaffold a new design system component with all required files and registration steps. Use when asked to add, create, or scaffold a new design system component.
icon: widgets
displayDescription: "Scaffolds a new design system component (a typed React component, a token-only CSS stylesheet, and a Storybook stories file), then registers it in the build-enforced component registry and design.md. Enforces the ds- BEM naming prefix, semantic token usage, and the correct stories format without needing reminders."
invoke: ["add a [Name] component","create a [Name] component","scaffold [Name]"]
---
# new-component
Scaffold a new design system component: the three component files plus the build-enforced registration steps.
## When invoked
Use this skill any time you are asked to add or create a new component to the design system — phrases like "add a [Name] component", "create a [Name] component", "scaffold [Name]".
Not for a widget: the chat (`src/chat/`) is a kind of its own, with its own registry (`src/chat/registry.json`) and validator (`scripts/validate-chat-widget.mjs`), and does not go through this flow. The one way the two meet: when the chat widget starts composing a new component, that component's name joins `parts` in the chat registry, which the validator holds to the widget's own imports in both directions.
## Instructions
1. **Ask** for the component name (PascalCase) and a one-sentence description of its purpose, if not already provided.
2. **Read these reference files before writing anything:**
- `src/components/Button/Button.tsx` — structural reference for a button-or-anchor component (own-props split, ref forwarding, rest spread, BEM class usage, conditional rendering)
- `src/components/Input/Input.tsx` — structural reference for a form control (native `onChange` + `onValueChange` convenience callback, label/helper/error wiring)
- `src/components/Badge/Badge.css` — CSS token reference (no raw hex/pixels, semantic token usage)
- `src/components/Badge/Badge.stories.tsx` — stories file reference (`satisfies Meta`, `StoryObj`, autodocs)
- `website/public/schema/component.schema.json` — the component contract as data. CLAUDE.md's Component Anatomy paragraph **The props interface is also the published schema** owns what it locks and why; `node scripts/validate-json-schemas.mjs` is the check. The component's own schema under `website/public/schema/components/` and its page's For agents dialog derive from the props interface and need no work.
- `src/tokens/registry.json` — the generated index of every semantic token by category (which CSS file owns which category is the `new-token` skill's step 2, backed by `SEMANTIC_FILES` in `scripts/generate-token-registry.mjs`; the registry is the one place that lists all of them)
- `design.md`'s Components intro, the two paragraphs **Shared primitives, never redrawn** and **Surfaces by family** — the one drawing of every small thing a component needs (the three control indicators, the chosen mark, the visually-hidden class, the close button, the exclusive set) and the surface treatment each family of component takes; `src/components/Checkbox/Checkbox.tsx` is the reference for exporting an indicator beside its control, and `src/components/SelectionCard/SelectionCard.tsx` for a host composing one
- `.claude/skills/design-qa/SKILL.md` — read its "The craft checks" rubric **before writing any CSS** (and "The eye" for when the component lands on a page): the component faces that pass as a gate before it can register (step 4), and polish built in beats polish patched on after
3. **Create the directory** `src/components/ComponentName/` and write these three files. For an **overlay component** (anything that dismisses, traps focus, or locks scroll), first read CLAUDE.md's Component Anatomy exception and design.md's Components-intro overlay contract: import the shared behavior hooks from `src/behaviors/` rather than registering your own document listeners — Dialog is the reference modal consumer, and Popover the reference light-dismiss one (`useLayer` with `dismissOnOutsidePress` and `getElements`, no focus scope). A component that handles Escape in its own keydown handler (a listbox closing its list) calls `event.preventDefault()`, which the stack respects, so a Dialog around it stays open. `scripts/validate-overlay-listeners.mjs` fails the build on a document key or press listener outside its sanctioned table. A panel anchored to a control (a menu, a listbox, a picker, a popover, a tooltip) is placed by `src/behaviors/useAnchoredPosition.ts`, never by `position: absolute` CSS, which a scrolling table or a sidebar clips: give it the preferred side, key its entrance on the `data-side` the behaviour writes, and add a story to `src/stories/AnchoredPopups.stories.tsx` that opens it inside a clipping box (design.md's "Popups are placed by one behaviour" owns the contract; Dropdown is the reference list, ThemePicker the reference menu). Three files is the norm, not a limit: a component may split extra modules out beside them (`ShaderField` keeps its hook and its shader source in sibling `.ts` files). If you do, read the packaging note in step 5 — a sibling `.ts` module does **not** get a deep-import subpath for free. One family-shaped exception: when the component belongs to an established shared-folder family (most of the recharts-backed charts live as single `.tsx` files inside `src/components/Chart/`, and the Inspector panel controls inside `src/components/Inspector/` sharing `Inspector.css`; the registry entries' `folder` values are the authoritative membership lists), add the file to that folder instead of creating a new directory, and set the registry entry's `folder` field to the shared folder's name — the doc block of `scripts/validate-component-registry.mjs` documents the field, and the existing `Chart/` entries are the pattern to copy. Two independent axes, not one: `folder` records where the implementation lives, and the registry's `recharts` flag records which barrel it ships from — FunnelChart is the precedent for a recharts-backed chart in its own folder (it composes the family's `Chart.css` chrome without living in `Chart/`). Joining the shared folder is a fit call, not a dependency test. A multi-series chart takes its default colours from `getChartSeriesColors()` in `Chart/palette.ts` — which emits `var()` references to the `--color-chart-series-*` ramp, so series colours follow a live theme switch — never a local palette array. A recharts-backed chart declares `'use client'` (recharts calls client-only React APIs and ships no boundary of its own) and sizes its tick and legend text with `readTokenPx('--font-caption-size', '12px')` from `Chart/palette.ts`, never a literal `fontSize`. It also spreads `useChartAnimation(data)` from `Chart/useChartAnimation.ts` into every series, so it animates on mount and on new data and never on a resize (design.md's chart-set spec owns the contract, the ScatterChart timer case included). And every panel-scale chart — recharts-backed or not — wears the family's shared card chrome by importing `Chart.css` and composing its `ds-chart` wrapper/header/body classes, and takes a `bare` prop (chrome off: no border, padding, or fill) so it can drop straight into a Panel that supplies the surface; a new chart ships the same way (only inline-scale pieces like a sparkline or a legend tile stay chromeless).
### File 1: `ComponentName.tsx`
- Named export (not default)
- BEM class naming with `ds-componentname` root prefix (e.g. `ds-button`, `ds-badge`)
- Modifier classes follow `ds-componentname--variant` pattern
- Imports CSS: `import "./ComponentName.css"`
- If the component renders as `<a>` when an `href` prop is passed, follow the Button pattern of conditional element rendering
- A continuous pointer gesture (a drag, a resize grip, a scrub, a move) writes the DOM per frame and commits React state once, on release: the move handler sets the style, the custom property or an SVG path's `d` straight on the node, and the pointer-up handler does the one `setState`. Per-move state re-renders everything under that state on every frame, and React counts a commit that ends with the next move already queued as a nested update, so a real drag over a re-animating sibling can hit the update-depth limit and unmount the root (the site lost a whole page to exactly that). The floating card's move in `src/chat/ChatLauncher.tsx` is the reference implementation (the position variables written per frame, one `setFloatPos` on release); its widen grip is the pure-DOM case, where the width lives on `<html>` and nothing needs committing. A grip a visitor can press on a served page also gets an entry in `scripts/gestures.json`, with the setup that reaches it, so the gesture stress check drives it in `verify` and CI (CLAUDE.md's Gestures row owns the registry). One exception: when the subtree the per-move update re-renders is small (a fictional `GadgetKnob` whose state drives only its own dial), per-move state is fine, and a comment beside the handler says so, so the next reader knows it was judged rather than missed
**The component API contract — every one of these, no exceptions.** This package is published to npm, so the props interface is a public contract. Getting it wrong is a breaking change later. `src/components/Button/Button.tsx` (button-or-anchor) and `src/components/Input/Input.tsx` (form control) are the reference implementations. Items 2-4 are build-enforced (`scripts/validate-component-contract.mjs` reads the syntax, and the props-contract story in `src/stories/Contract.stories.tsx` renders the component with a ref and a `data-testid` and fails unless both reach one element), so a slip fails the build rather than shipping; the rest are yours to get right. State a consumer can own follows the controlled/uncontrolled pair convention in CLAUDE.md's Component Anatomy (`x`/`defaultX`/`onXChange`), and every accessible name the component writes in English is a `*Label` prop with that text as its default.
1. **`'use client'` on the first line** — if and only if the component uses hooks, event handlers, or browser APIs. **Purely presentational components must NOT have it** (see `src/components/Table/Table.tsx`), or consumers lose the ability to render them from a React Server Component. "Uses hooks" counts what the component imports too: a shared hook file or a package that renders client-only (recharts) needs the directive on the component, and `scripts/validate-component-registry.mjs` fails the build when one is missing.
2. **Split the props type in two.** Own props as a `type`, then an exported `interface` that merges in the native element's props:
```ts
type ComponentNameOwnProps = { /* ...props this component owns... */ };
export interface ComponentNameProps
extends ComponentNameOwnProps,
Omit<React.ComponentPropsWithoutRef<'div'>, keyof ComponentNameOwnProps> {}
```
Add `| 'type'` (or any other attribute the component hardcodes) to the `Omit` list.
3. **Take a ref onto the primary DOM node**, in one of two forms the validator accepts. A new component uses React 19's form: a named function component that destructures `ref` from its props and passes it to the node, with the props interface built on `ComponentPropsWithRef<'div'>` instead of `ComponentPropsWithoutRef` (the package's react peer is `^19`, so every consumer has ref-as-prop; the function's name is its DevTools name, so it needs no `displayName`):
```tsx
export function ComponentName({ className = '', ref, ...rest }: ComponentNameProps) {
return <div {...rest} ref={ref} className={['ds-component-name', className].filter(Boolean).join(' ')} />;
}
```
The existing components use `React.forwardRef` with `ComponentName.displayName = 'ComponentName'` after it, which stays valid. The two are identical to a consumer, so there is no migration: leave a forwardRef component alone unless you are rewriting it anyway. If the component already keeps an internal ref (focus trap, click-outside, picker trigger), merge them:
```ts
const setRef = (node: HTMLDivElement | null) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node); else if (ref) ref.current = node;
};
```
4. **Spread `{...rest}` onto that same node**, placed *before* the component's own attributes so those win; a consumer-overridable default (a landmark's `aria-label`) goes before the spread instead, so a passed value wins (see Accessibility below). This is what makes `data-testid`, `aria-*`, `autoComplete`, `maxLength` and form-library registration work.
5. **Event handlers keep native React signatures.** `onChange` must be `React.ChangeEventHandler`, never `(value: string) => void` — that shape breaks react-hook-form, Formik and TanStack Form. Put the convenience callback under a name matching the value's shape, and fire both:
| Value shape | Convenience prop |
|---|---|
| string / number | `onValueChange` |
| boolean | `onCheckedChange` |
| array | `onValuesChange` |
```ts
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
onValueChange?.(e.target.value);
};
```
6. **Never invent a prop that shadows a native one with different semantics.** Prefer `variant` (not `priority`/`kind`) and `disabled` (a real boolean, never a value inside a `state` enum). **Figma variant properties are not code props** — `hover` and `active` belong to CSS pseudo-classes, so a `state` prop that includes them has two sources of truth. Where a collision is unavoidable and intentional (`size` vs the native character-width attribute, `title` vs the native tooltip), say so in the prop's JSDoc.
**`variant` is emphasis, `tone` is colour** (CLAUDE.md's Component Anatomy states the rule; design.md's Components intro owns the details). Never put a colour value in an emphasis union: a fictional `GadgetPill` that needs a red delete look takes `variant?: 'primary' | 'secondary' | 'tertiary'` plus `tone?: 'default' | 'neutral' | 'destructive'`, never `variant: 'destructive'`. A status-bearing component keeps the five status roles on `variant` instead, and a data entry the component renders (a menu item, a command row) marks danger with `destructive?: boolean`. If the component is a rework that already shipped a mixed union, the moved value stays as a `@deprecated` alias that renders unchanged (item 8). `scripts/validate-prop-vocabulary.mjs` enforces the shape, and fails any internal caller still passing a deprecated prop or a legacy value.
7. **Discard props that would be invalid on the rendered node** rather than spreading them — e.g. `maxLength` on a `<div role="combobox">`. Destructure with a `_` prefix (`maxLength: _maxLength`) and document why in the JSDoc; the ESLint config allows `^_`. A form control's `name` is never one of these: it reaches the form, through a hidden input when the control has no native one (design.md, "A control reaches a form through `name`").
8. **Deprecate, never remove.** Keep the old prop working and mark it `@deprecated` with the replacement named — and put a description sentence **before** the tag, or the prop renders as a blank cell (see 9). `className` stays wherever it already is (usually the wrapper) — moving it is a silent visual break.
9. **A link renders through the link component, never a raw `<a href>` — build-enforced.** A component that takes an `href` (its own, or an item's) adds an optional `linkComponent?: LinkComponent` prop and renders every link with the resolved component, so a consumer's router (Next.js's `Link`) navigates without a reload. In a client component, resolve it with `const Link = useLinkComponent(linkComponent);` from `src/behaviors/LinkProvider.tsx` at the top of the render, before any early return (prop, else the nearest `LinkProvider`, else `<a>`); in a server-renderable one, `const Link = linkComponent ?? 'a';`, since context cannot reach it, and say so in the prop's JSDoc. Button.tsx and Breadcrumb.tsx are the two patterns, and design.md's Components intro owns the contract. A link that is genuinely not navigation (an in-page jump the component scrolls itself) is a row in `SANCTIONED_ANCHORS` in `scripts/validate-component-contract.mjs`, with the reason.
**A number, a date or a time is written in the resolved locale, never the runtime's — build-enforced.** The same shape as the link: an optional `locale?: string` prop, resolved with `const locale = useLocale(localeProp);` from `src/behaviors/LocaleProvider.tsx` in a client component, or `locale = DEFAULT_LOCALE` (from `src/behaviors/locale.ts`) in a server-renderable one, then every `Intl` format, collator and `toLocale*String` call takes that locale. A fictional `GadgetMeter` printing `1,204 widgets` formats the number in `locale` and keeps "widgets" in a `*Label` prop: the locale formats, it never translates. A calendar reads its first weekday from `weekStartOf(locale)` and its names from `weekdayNamesIn` rather than a hand-written list. `scripts/validate-locale-formatting.mjs` fails the build on a call with no locale, a hand-written month or weekday name, or a server-renderable component reading the provider; design.md's Components intro owns the contract.
**A key the component answers to from outside its own focused element goes through `useShortcut` — build-enforced.** A fictional `GadgetViewer` overlay whose arrows step pages binds them with `useShortcut('arrowright', next, { scope: panelRef, enabled: open })` from `src/behaviors/shortcuts.ts`, never a document listener, so they stay quiet in text fields and behind other overlays and never take Escape from the layer stack. A shortcut the component only displays is a `keys?: Shortcut` prop in the shortcut grammar (`mod+shift+p`), drawn through Kbd's `keys` or `formatShortcut`, with a `keyLabels` prop for other keyboards; keep a literal display prop the component already ships. `scripts/validate-overlay-listeners.mjs` fails the build on a document key listener; design.md's "Keyboard shortcuts go through one listener" owns the rules.
**Lay it out from the reading direction — build-enforced.** Write every side logically (`margin-inline-start`, `inset-inline-end`, `border-start-start-radius`, `text-align: start`); multiply any horizontal transform by `var(--ds-inline-sign, 1)` and import `src/behaviors/direction.css`; switch on `logicalArrowKey(event.key, event.currentTarget)` from `src/behaviors/direction.ts` for left and right; measure a drag from the inline start with `isRtl`; ask a popup for `start`/`end`; and give a glyph that points along the reading direction the `ds-mirror-rtl` class (an icon a consumer names takes `mirrorClass(name)`). A fictional `GadgetSlider` whose thumb slides by value multiplies its translate by the sign and reads ArrowLeft as an increase in Arabic. A side that genuinely belongs to the screen (a map, a chart axis, a pointer position) takes `/* ds-allow(physical): <reason> */`. `scripts/validate-logical-properties.mjs` fails the build otherwise; check the component with Storybook's Direction toolbar set to right to left.
10. **Compose the shared primitives; never redraw one — build-enforced.** A component that offers a choice renders `CheckboxIndicator`, `RadioIndicator` or `SwitchIndicator` (exported beside Checkbox, RadioButton and ToggleSwitch) and carries the role, the state and the `ds-control-host` class on its own root; a list or menu row marks its selected item with `ChosenMark` (from the Dropdown module); text that is read but not seen takes `ds-visually-hidden`; a header's close is a compact tertiary `CircularButton`; a mutually exclusive set of pills is a `SegmentedControl`. A fictional `GadgetPicker` that wants a tick beside each row renders the checkbox indicator, never a `check_box` glyph or a bordered `<span>` of its own. `scripts/validate-shared-primitives.mjs` fails the build on a second drawing of any of these; design.md's Components intro ("Shared primitives, never redrawn") is the list, and a departure with a reason is a row in that script's `SANCTIONED` table.
11. **Give every own prop a JSDoc description — this is build-enforced.** That JSDoc is the single source for Storybook's props table, the `.d.ts` consumers get, and the machine-readable prop API the site's `/api/mcp` route serves to any agent and the site chat's `get_component` tool repeats to visitors (`website/src/data/component-api.generated.ts`) — so a prop description is public copy strangers' agents and site visitors both read, and `scripts/validate-component-api.mjs` leak-screens it (a hit there is a leak in the npm tarball too). All three consumers see the same parse: the settings live in `scripts/component-docgen.mjs`, mirroring `.storybook/main.ts`. `scripts/validate-prop-docs.mjs` fails the build on a prop without a description. Two traps it catches:
```ts
/** Visual treatment */
variant?: 'primary' | 'secondary';
/**
* Legacy alias for `variant`. ← without this line the row renders blank:
* the parser moves the tag and everything
* @deprecated Use `variant` instead. after it into a separate `tags` field
*/
priority?: 'primary' | 'secondary';
```
And **never write a prop `description` into the story's `argTypes`.** Those entries override docgen, so a description there shadows the JSDoc and drifts from it. Stories set `control` and `options` only; the source owns the words.
**If the component is a labelled form control, compose it inside `Field`** (`src/components/Field/Field.tsx`) rather than re-implementing the scaffolding. Field owns the label and its `htmlFor`, the required marker, the helper/error text, the generated ids, and the `aria-describedby` / `aria-invalid` wiring — several components each rolled their own before it existed, and two of them silently diverged (Dropdown announced neither its helper text nor its error state). Pass `className` (your own root classes), `label`, `helperText`, `error`, `required`, `disabled`, `size` and `id`; render the control as children. For a set of controls with no single labelable element (a checkbox or radio group), add `group`, which renders the label as a `<span id>` for the group node to point `aria-labelledby` at — CheckboxGroup and RadioGroup are the live adopters. Field owns no layout, so your root class keeps its own flex/gap.
Two details worth knowing before you reach for it:
- `htmlFor` only associates with **labelable** elements (input, select, textarea, button…). If your control is a composite built from a `div` — a `role="combobox"` trigger, say — point `aria-labelledby` at `` `${id}-label` `` instead, which is the id Field puts on its label.
- Content that belongs *opposite* the helper text (a character counter, a unit) goes in Field's `aside` prop, not a hand-rolled footer.
### Accessibility — what axe will not catch
The story tests run axe in `'error'` mode, but axe only catches roughly a third of WCAG issues, and it passes every rule below while a screen-reader user hears the wrong thing. Write each one in from the start:
- **Everything the eye is told, the ear is told.** When a visual mark is `aria-hidden` (a badge, a check, a status dot, an icon standing for a state), its meaning needs a text equivalent: a visually hidden span or a suffix on the owning control's label. Cover **every shape the prop accepts**. A `badge?: number | true` that announces the count but not the dot is the classic miss, and so is a step indicator whose "complete" check is hidden with nothing said in its place.
- **Name every landmark and every named-content region.** A `<nav>`, an `<aside>`, a labelled list of steps, a figure: each gets an accessible name, because a page with two unnamed navs gives a screen-reader user two identical entries. Prefer `aria-labelledby` pointing at the component's own visible heading (ids from `useId()`, never hard-coded, so two instances cannot collide) and `aria-describedby` at its subtitle. When there is no visible heading, set a sensible default `aria-label`.
- **Every default label is overridable, by the consumer's own attribute.** Write the default *before* `{...rest}` (`<nav aria-label="Main" {...rest}>`), so a passed `aria-label` wins with no extra prop. A string the component writes itself and the consumer cannot reach (an overflow button's "More", a close button's "Close") needs a prop of its own, named for the control (`navigationLabel`, `overflowLabel`): hard-coded English cannot be localised.
- **A shape the type accepts is never silently dropped.** When one prop type feeds two renderings (inline actions and an overflow menu, say), check that every field survives both. If a sub-surface cannot honour a field (a menu row that cannot be a link, a row that draws no badge), narrow that surface's type with `Omit<>` so the compiler says so. A JSDoc "ignored in the menu" note still ships a trap.
- **Hidden copies leave the accessibility tree.** A duplicate kept for animation (a collapsing title, a cross-fade) is `aria-hidden` while it is the invisible one, and a region collapsed or minimised out of view is `inert` so its controls leave the tab order too. Exactly one copy of any text is announced at a time.
- **Native semantics first, ARIA second, and never ARIA for coverage.** Nothing counts ARIA: `/foundations/accessibility` dropped its ARIA census because it read a correct native element as a shortfall. A `<table>`, `<blockquote>` or heading needs nothing added, and an `aria-label` on a plain `div` or `span` is prohibited by ARIA 1.2. If the component is correct with no ARIA, leave it with none.
### File 2: `ComponentName.css`
- CSS custom properties exclusively — **no hardcoded hex colours**, no raw `rgb()`/`rgba()`
- **The action colour is reserved, and the build holds the line.** Outside a focus `outline`, a component that references `--color-action-primary-bg` fails `scripts/validate-action-colour.mjs` until it is added to that script's `SANCTIONED` table with the role the use plays (design.md lists the roles). Decide the role first; if none fits, the use is decoration and wants a different token
- Icons are sized by setting `--icon-size` on the icon element to a step from the `--icon-size-*` scale (`src/tokens/registry.json` is the list) — never `font-size` or raw pixel dimensions on an icon
- Transitions/animations compose `--motion-duration-*` with `--motion-ease-*` from `tokens-motion.css` — never literal timings like `0.2s ease` (new code must use the motion tokens from the start)
- The same rule has a TypeScript half: a JS timer (a hover delay, an auto-dismiss, a settle timeout) takes its default from the shared constants in `src/tokens/motion.ts` — import the matching constant, or add a named one there deliberately, never a literal ms value in the component. Schedule timings are deliberately left alone by the reduced-motion guard; a constant that paces an animation must be guarded by its component in JS instead (design.md's Motion section records the contract and the exceptions)
- All other spacing, padding, gap, border-radius, font sizes must use semantic tokens from `tokens-light.css` / `tokens-typography.css`
- **A control that can share a row takes the row height.** A field, a button, a picker, a segmented set or a filter chip sets `height: var(--control-height-default)` (and `--control-height-compact` at `size="compact"`) with `box-sizing: border-box` and horizontal padding only, so it lines up with every control beside it at both sizes; design.md's Control Height Scale owns the roster and the exceptions (a tag, a keyboard hint). Never a literal 40px or 32px
- A z-index of 10 or more is a stacking layer: `var(--layer-*)` for the role (`--layer-dropdown` for an anchored menu, `--layer-floating` for a hover card, `--layer-modal` for a dialog; `tokens-light.css` lists them lowest first), or `calc()` off one when the surface sits exactly a step from it. Order inside the component stays a plain integer under 10; a larger local number takes `ds-allow(stacking)` directly above it (`scripts/validate-stacking.mjs` enforces it). A tooltip takes no layer of its own choosing: it calls `useTopLayer` from `src/behaviors/useTopLayer.ts`, which paints it above every layer, and the same validator fails a tooltip that does not
- A viewport `@media` query switches at a width from `src/tokens/breakpoints.ts` (`max-width: N`, or `min-width: N + 1` for the same step seen from above), and a JS query is built from that module's `mediaBelow` / `mediaAbove`, never a literal string. A content-driven width outside the set takes a `ds-allow(breakpoint)` directive directly above the query; `scripts/validate-breakpoints.mjs` fails the build otherwise. Prefer a container query, or measuring the component's own width, when the layout depends on where the component sits rather than on the viewport
- **If a value genuinely cannot use a token** (colour-space physics, a glyph inside a control's geometry, decorative timing tuned by eye), sanction it *at the site*: a `/* ds-allow(<category>): <reason> */` line inside a comment at the value (`ds-allow-file(...)` in the header for file-wide cases), plus a sentence in the component's design.md spec. The category set is the closed list in `scripts/validate-css-directives.mjs` (grammar is build-enforced). **Never add an exception to the token-audit skill** — it reads the directives; it maintains no list
- **The surface is the family's, not the component's.** Before writing a fill, border, radius, hover, selected or disabled rule, find the component's family in design.md's Components intro ("Surfaces by family") and take its fill, border, radius, hover, selected and disabled tokens from there, never from memory or from this page. A fictional `GadgetRow` that invents a fourth radius for "a row like the others" is the drift the `component-consistency-audit` skill exists to find; a departure the design calls for is a sentence in the component's design.md spec, written in the same change
- **A card a chat reply carries joins the chat card family.** Add its root class to the `:where()` list in `src/behaviors/chat-card.css`, import that file from the component module, and read its `--ds-chat-card-*` properties for the fill, border, corner, inset, edge, rhythm and one-row height instead of the tokens behind them; design.md's "The chat cards are one family" is the rule set it starts from. `scripts/validate-chat-cards.mjs` fails an ai component that draws a card shell of its own outside the list, and a member that restates a value the recipe owns.
- Section comments grouping related rules (e.g. `/* Base */`, `/* Variants */`, `/* States */`)
- **No dark-theme overrides and no `prefers-color-scheme` queries** — dark mode comes entirely from the semantic tokens (every token has a light and dark value). The rare sanctioned exception is recorded in the component's own `design.md` spec, never assumed
### File 3: `ComponentName.stories.tsx`
- Import: `import type { Meta, StoryObj } from '@storybook/react-vite'`
- Meta uses `satisfies Meta<typeof ComponentName>`, and sets `component: ComponentName`. The props-contract story finds the component through that field and borrows its props from the meta's `args` or the first story with `args`, so a component the meta does not name, or whose stories supply its required props only through a `render` function, fails as uncovered. When the stories cannot carry the props as args, add a `FIXTURES` entry in `src/stories/Contract.stories.tsx`
- `title: 'Components/ComponentName'`
- `tags: ['autodocs']`
- `parameters: { layout: 'centered' }` for compact components; `'padded'` for full-width ones (cards, layouts, nav)
- One named `StoryObj` export per meaningful variant or state combination
- Story names are descriptive (e.g. `Default`, `WithIcon`, `Disabled`, `Small`)
- A component whose contract is *behavior* (dismissal, focus, keyboard) gets a `play` function asserting it — `Dialog.stories.tsx` is the reference (stories run as tests, so the assertion is CI). End the play in the component's resting state so snapshots stay stable
4. **Pass design QA before registering.** Invoke the `design-qa` skill on the new component (its "Inside new-component" section defines gate mode): render every story in both themes, magnify the joins, corners, and states, line the component up against its nearest rendered siblings, and loop on fixes until a full pass yields no defect or polish findings. One mechanical note for this pass: `npm run storybook` cannot cold-start here, because its `prestorybook` hook runs the `validate-registry` chain, which correctly fails on the still-unregistered folder — use a Storybook that is already running, or start the `storybook-raw` configuration in `.claude/launch.json` (port 6016; it invokes Storybook directly, bypassing the `prestorybook` hook, and exists for exactly this pre-registration case). Fold any open direction calls into the hand-off in step 7. Rendering correctly is necessary and nowhere near sufficient — this step is where "the buttons aren't good enough" gets caught without anyone having to say it.
The same gate has an accessibility half, run on the rendered stories rather than the source. For every story, list what assistive tech is actually given: each landmark with its name, each interactive element with its accessible name and state (`aria-current`, `aria-expanded`), and any text inside `aria-hidden` or `inert` regions. A short script in the browser that queries `nav, aside, [role], a, button` and reads `aria-label`, `aria-labelledby`, `innerText` and `closest('[inert]')` produces that list. Check it against the Accessibility rules under File 1. Every state a story shows must appear in the list, and no text may appear twice. Then run the `accessibility-audit` skill's structural audit (its step 3, the **[manual]** items) on the component. Fix and re-list until both are clean. A behaviour the list proves (a badge announced, a hidden copy muted) is worth a `play` assertion, so that it stays proven in CI.
The gate's last half is the API and the family: run the `api-consistency` skill scoped to the new component and its nearest family (its shared folder or registry category), then the `component-consistency-audit` skill scoped the same way. A new prop that names a size, a colour or a callback differently from its siblings, or a surface that takes a fill, radius or hover its family does not, is cheapest to fix before anything consumes it. Fix every finding before registering.
5. **Register the component** in `src/components/registry.json` — add an **object** to the `components` array (alphabetical by `name`):
```json
{ "name": "MyComponent", "label": "My component", "slug": "my-component",
"description": "One line, ending in a full stop, under 160 characters.",
"category": "forms", "client": true }
```
The `description` is shipped copy (it feeds the sidebar, page metadata, and README): a verbless one-line fragment ending in a full stop, written per the register table in `content-design.md`. `category` must be one of the registry's `categories` — add a new one deliberately rather than inventing one per component. The `label` has a build-enforced length ceiling from an unexpected direction: `scripts/validate-page-summaries.mjs` derives a "How do I use <label>?" chip for every component's TLDR panel and holds it to the suggestion budget, so a long label fails the build there — keep labels short and the failure never appears. `client` must match whether the file declares `'use client'`; the validator compares them and fails on a mismatch, so the registry can never document a component as server-renderable when it isn't. A component that imports recharts also sets `"recharts": true` — the flag records which barrel it ships from, and the doc block in `src/components/registry.ts` owns the field list. **The sidebar nav entry, sitemap, breadcrumbs, mega-nav and the page's title and description all derive from this entry** — never hand-add a nav link. This file is the single source of truth for the component count; `scripts/validate-component-registry.mjs` runs before every build and **fails if the folder is unregistered**. Confirm the registration itself with `node scripts/validate-component-registry.mjs` — the full `npm run validate-registry` chain stays red by construction until the website page and `design.md` section exist (`scripts/validate-website-surfaces.mjs` requires both for every registered component), so the full chain runs at the end of step 7, not here. Its leading generator scripts (the authoritative list is the `validate-registry` entry in the root `package.json`) regenerate every derived surface a new component touches: the README's component count/list, the package barrels (`src/index.ts`, or `src/charts.ts` if the component imports recharts — the generator routes by detecting the import and **fails when the registry entry's `recharts` flag disagrees with what the module actually imports**, so set the flag when the import is real; never hand-edit either barrel), the component prop API (`website/src/data/component-api.generated.ts`), the component's markdown twin (`website/public/components/<slug>.md`), its JSON schema and the schema index (`website/public/schema/components/<slug>.json`, `website/public/schema/components.json`; `scripts/validate-json-schemas.mjs` then proves the new file against the meta-schema), the shadcn registry item (`website/public/r/<slug>.json`, plus its `registry.json` index), the consumer agent skills' component catalogue (`references/components.md` in the library skill's folder under `website/public/skill/` — `AGENT_SKILLS` in `scripts/brand.mjs` owns the folder names), and the site chat's corpus (`website/src/data/site-corpus.generated.ts` — it carries the component site map and every registry description), and the dependency graph's data (`website/src/data/dependency-graph.generated.ts` — the new component becomes a node, with its token uses and imports as edges), and the accessibility coverage figures (`website/src/data/a11y-coverage.generated.ts` — its story total and storied-component share move with the new component, and an overlay joins its rosters by import). **Commit every file those generators rewrote in the same push as the registration** — alongside it, or as a separate `chore(generated):` commit when the regeneration would bury the real change (both are sanctioned; the `ship` skill's step 2 owns the rule). CI's drift guard diffs the tree after the generators run, so a regenerated file left uncommitted fails the build. The component is then automatically part of the published `rift-ds` API — both the barrel and the `./components/*` deep-import subpath. **That wildcard maps `.tsx` only.** A sibling `.ts` module in the same folder (a hook, a data table, a shader source) reaches consumers through the barrel if the `.tsx` re-exports it (a value re-export from a `.tsx` trips the `react-refresh/only-export-components` lint rule, so it sits under an `eslint-disable` for that rule with the reason written beside it, as `ShaderField.tsx` does; `export type` needs none), but has no deep-import path of its own until you add one — and a subpath has three homes, not one: `SUBPATHS` in `scripts/package-manifest.mjs` (the authoritative record of the ones that exist), the root `package.json` `exports` field it is mirrored into by hand, and the dist entry list in `vite.lib.config.ts` (the `new-token` skill's step 5 is the same recipe). `scripts/validate-package-exports.mjs` fails the build if any of the three disagree, so a subpath cannot reach npm with no built module behind it. Decide deliberately: a module that is genuinely internal wants no subpath, and one you document as an escape hatch needs one. `scripts/validate-package-exports.mjs` checks the answer either way — an unexported `.ts` sibling must be re-exported by the component or named in that script's `INTERNAL_MODULES` list with a reason. A sibling `.tsx` is the other way round: the `./components/*` wildcard and the barrel publish it the moment it exists, so a part that is not public API (a grid two pickers share, a toggle one component owns) goes in `INTERNAL_PARTS` in `scripts/package-manifest.mjs` with its reason, which closes its deep path with a `null` export and keeps it out of the barrel; copy `sourceExports()` into the root `package.json` after adding one. If the component needs a new runtime dependency, stop and ask: the package's runtime deps are the react peer and the optional peers (`peerDependenciesMeta` in the root `package.json` is the list: recharts for the charts, and the chat widget's own for its subpaths), and adding one is a packaging decision.
One exception: a **docs-only helper** (a component that exists purely for the website/Storybook docs — the registry's `docOnlyHelpers` array is the authoritative list; note the public `Swatch` component is *not* one of them) goes in the registry's `docOnlyHelpers` array instead of `components` — it gets no showcase page, no barrel export, and doesn't count. Putting it in `components` fails the build for a missing website page.
6. **Document it in `design.md`** — add a short component spec section (class name, tokens used, key behaviours), following the format of the existing component sections.
7. **Hand off the website work.** A component is not done until it has a showcase page: a `page.tsx`, a `page.module.css`, a `layout.tsx` containing exactly `export const metadata = componentPageMetadata("<slug>");`, and a preview entry under its slug in `website/src/components/ComponentPreviews/ComponentPreviews.tsx`. The preview is the only surface still hand-maintained — each entry holds a bespoke live miniature. Everything else (the index's category section, sidebar accordion, sitemap, breadcrumbs, title, description) derives from the registry entry. All of it is build-enforced by `scripts/validate-website-surfaces.mjs` and `scripts/validate-page-titles.mjs`. Ask the owner: "Should I add the website documentation page now? (invokes the `component-doc-page` skill)" — and whoever does that work must complete every registration above, then run `npm run validate-registry`: with the page and the `design.md` section in place, everything but the Figma check (step 8) can go green.
Two surfaces are curated and not build-enforced: the /components featured row (`website/src/components/FeaturedComponents/FeaturedComponents.tsx`, a few component pages picked by hand, never a list to add to) and the playground's Components view, whose full list is staged from the per-category section files in `website/src/app/playground/sections/` and whose collage is `website/src/app/playground/views/CollageView.tsx`. Decide with the owner whether the new component earns a place there; nothing fails if it is left out, so the decision has to be made on purpose.
8. **Hand off the Figma work.** `scripts/validate-figma-nodes.mjs` fails until the component has a frame in the Figma kit, mapped in `website/src/data/figma-nodes.json`. That is the `new-figma-component` skill's job, run on its own (it can be a separate session): ask the owner whether to run it now or later. Until it runs, the `validate-registry` chain fails, and it runs before every root build, Storybook and `verify`, so nothing ships between step 7 and the Figma work.