new-component
Skill9 starsChanged 7 days agoMITView on GitHub
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.
8 other projects use a version of this.
Adopted by
Most recently taken up by
- codechup/claude-code-labtheir own version, first seen Nov 8, 2026
- Crawford-Young/claude-configtheir own version, first seen Oct 25, 2026
- ricci-labs/twisetheir own version, first seen Oct 20, 2026
- violacase/Nuxt4-testtheir own version, first seen Oct 13, 2026
- fangyuting/skillstheir own version, first seen Oct 11, 2026
- mark2685/agent-toolstheir own version, first seen Jun 12, 2026
Other versions8 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 8
- harunkurtdev/ros2-claude-code-template★ 214same name
- kartikmanimuthu/agentfork★ 5same name
- fangyuting/skills★ 1same name
- codechup/claude-code-lab★ 0same name
- Crawford-Young/claude-config★ 0same name
- mark2685/agent-tools★ 0same name
- ricci-labs/twise★ 0same name
- violacase/Nuxt4-test★ 0same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository33 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- accessibility-auditSkill
- animation-jump-checkSkill
- api-consistencySkill
- chat-qualitySkill
- checkpointSkill
- component-consistency-auditSkill
- component-doc-pageSkill
- content-auditSkill
- dependency-advisorySkill
- design-qaSkill
- dogfoodSkill
- drift-auditSkill
- first-deploySkill
- landSkill
- link-rotSkill
- new-pageSkill
- new-presetSkill
- new-templateSkill
- new-tokenSkill
- new-validatorSkill
- parkSkill
- pre-deploySkill
- release-postSkill
- releaseSkill
- responsive-sweepSkill
- security-auditSkill
- seo-auditSkill
- shipSkill
- super-shipSkill
- token-auditSkill
- token-renameSkill
- visual-reviewSkill
The file
We split this skill into the rules it gives an agent
4 rules. 0 are common, 1 is distinctive, and 3 are very specific to this project.
0
Common
Most of the 7 edited versions we read keep these.
1
Distinctive
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 15
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", …
- Very specific: line 40
If the component renders as <a> when an href prop is passed, follow the Button pattern of conditional element rendering
- Very specific: line 55
Add | 'type' (or any other attribute the component hardcodes) to the Omit list.
- Very specific: line 145
All other spacing, padding, gap, border-radius, font sizes must use semantic tokens from tokens-light.css / tokens-typography.css
“Common” is measured: kept by at least 6 in 10 of the 7 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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
- Ask for the component name (PascalCase) and a one-sentence description of its purpose, if not already provided.
- 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 (nativeonChange+onValueChangeconvenience 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.mjsis the check. The component's own schema underwebsite/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 thenew-tokenskill's step 2, backed bySEMANTIC_FILESinscripts/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.tsxis the reference for exporting an indicator beside its control, andsrc/components/SelectionCard/SelectionCard.tsxfor 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
- 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 fromsrc/behaviors/rather than registering your own document listeners — Dialog is the reference modal consumer, and Popover the reference light-dismiss one (useLayerwithdismissOnOutsidePressandgetElements, no focus scope). A component that handles Escape in its own keydown handler (a listbox closing its list) callsevent.preventDefault(), which the stack respects, so a Dialog around it stays open.scripts/validate-overlay-listeners.mjsfails 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 bysrc/behaviors/useAnchoredPosition.ts, never byposition: absoluteCSS, which a scrolling table or a sidebar clips: give it the preferred side, key its entrance on thedata-sidethe behaviour writes, and add a story tosrc/stories/AnchoredPopups.stories.tsxthat 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 (ShaderFieldkeeps its hook and its shader source in sibling.tsfiles). If you do, read the packaging note in step 5 — a sibling.tsmodule 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.tsxfiles insidesrc/components/Chart/, and the Inspector panel controls insidesrc/components/Inspector/sharingInspector.css; the registry entries'foldervalues are the authoritative membership lists), add the file to that folder instead of creating a new directory, and set the registry entry'sfolderfield to the shared folder's name — the doc block ofscripts/validate-component-registry.mjsdocuments the field, and the existingChart/entries are the pattern to copy. Two independent axes, not one:folderrecords where the implementation lives, and the registry'srechartsflag records which barrel it ships from — FunnelChart is the precedent for a recharts-backed chart in its own folder (it composes the family'sChart.csschrome without living inChart/). Joining the shared folder is a fit call, not a dependency test. A multi-series chart takes its default colours fromgetChartSeriesColors()inChart/palette.ts— which emitsvar()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 withreadTokenPx('--font-caption-size', '12px')fromChart/palette.ts, never a literalfontSize. It also spreadsuseChartAnimation(data)fromChart/useChartAnimation.tsinto 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 importingChart.cssand composing itsds-chartwrapper/header/body classes, and takes abareprop (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-componentnameroot prefix (e.g.ds-button,ds-badge) - Modifier classes follow
ds-componentname--variantpattern - Imports CSS:
import "./ComponentName.css" - If the component renders as
<a>when anhrefprop 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
dstraight on the node, and the pointer-up handler does the onesetState. 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 insrc/chat/ChatLauncher.tsxis the reference implementation (the position variables written per frame, onesetFloatPoson 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 inscripts/gestures.json, with the setup that reaches it, so the gesture stress check drives it inverifyand CI (CLAUDE.md's Gestures row owns the registry). One exception: when the subtree the per-move update re-renders is small (a fictionalGadgetKnobwhose 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.
'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 (seesrc/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, andscripts/validate-component-registry.mjsfails the build when one is missing.- Split the props type in two. Own props as a
type, then an exportedinterfacethat merges in the native element's props:
Addtype ComponentNameOwnProps = { /* ...props this component owns... */ }; export interface ComponentNameProps extends ComponentNameOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof ComponentNameOwnProps> {}| 'type'(or any other attribute the component hardcodes) to theOmitlist. - 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
reffrom its props and passes it to the node, with the props interface built onComponentPropsWithRef<'div'>instead ofComponentPropsWithoutRef(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 nodisplayName):
The existing components useexport function ComponentName({ className = '', ref, ...rest }: ComponentNameProps) { return <div {...rest} ref={ref} className={['ds-component-name', className].filter(Boolean).join(' ')} />; }React.forwardRefwithComponentName.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:const setRef = (node: HTMLDivElement | null) => { internalRef.current = node; if (typeof ref === 'function') ref(node); else if (ref) ref.current = node; }; - Spread
{...rest}onto that same node, placed before the component's own attributes so those win; a consumer-overridable default (a landmark'saria-label) goes before the spread instead, so a passed value wins (see Accessibility below). This is what makesdata-testid,aria-*,autoComplete,maxLengthand form-library registration work. Event handlers keep native React signatures.
onChangemust beReact.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 onValueChangeboolean onCheckedChangearray onValuesChangeconst handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { onChange?.(e); onValueChange?.(e.target.value); };Never invent a prop that shadows a native one with different semantics. Prefer
variant(notpriority/kind) anddisabled(a real boolean, never a value inside astateenum). Figma variant properties are not code props —hoverandactivebelong to CSS pseudo-classes, so astateprop that includes them has two sources of truth. Where a collision is unavoidable and intentional (sizevs the native character-width attribute,titlevs the native tooltip), say so in the prop's JSDoc.variantis emphasis,toneis 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 fictionalGadgetPillthat needs a red delete look takesvariant?: 'primary' | 'secondary' | 'tertiary'plustone?: 'default' | 'neutral' | 'destructive', nevervariant: 'destructive'. A status-bearing component keeps the five status roles onvariantinstead, and a data entry the component renders (a menu item, a command row) marks danger withdestructive?: boolean. If the component is a rework that already shipped a mixed union, the moved value stays as a@deprecatedalias that renders unchanged (item 8).scripts/validate-prop-vocabulary.mjsenforces the shape, and fails any internal caller still passing a deprecated prop or a legacy value.- Discard props that would be invalid on the rendered node rather than spreading them — e.g.
maxLengthon a<div role="combobox">. Destructure with a_prefix (maxLength: _maxLength) and document why in the JSDoc; the ESLint config allows^_. A form control'snameis 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 throughname"). - Deprecate, never remove. Keep the old prop working and mark it
@deprecatedwith the replacement named — and put a description sentence before the tag, or the prop renders as a blank cell (see 9).classNamestays wherever it already is (usually the wrapper) — moving it is a silent visual break. A link renders through the link component, never a raw
<a href>— build-enforced. A component that takes anhref(its own, or an item's) adds an optionallinkComponent?: LinkComponentprop and renders every link with the resolved component, so a consumer's router (Next.js'sLink) navigates without a reload. In a client component, resolve it withconst Link = useLinkComponent(linkComponent);fromsrc/behaviors/LinkProvider.tsxat the top of the render, before any early return (prop, else the nearestLinkProvider, 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 inSANCTIONED_ANCHORSinscripts/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?: stringprop, resolved withconst locale = useLocale(localeProp);fromsrc/behaviors/LocaleProvider.tsxin a client component, orlocale = DEFAULT_LOCALE(fromsrc/behaviors/locale.ts) in a server-renderable one, then everyIntlformat, collator andtoLocale*Stringcall takes that locale. A fictionalGadgetMeterprinting1,204 widgetsformats the number inlocaleand keeps "widgets" in a*Labelprop: the locale formats, it never translates. A calendar reads its first weekday fromweekStartOf(locale)and its names fromweekdayNamesInrather than a hand-written list.scripts/validate-locale-formatting.mjsfails 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 fictionalGadgetVieweroverlay whose arrows step pages binds them withuseShortcut('arrowright', next, { scope: panelRef, enabled: open })fromsrc/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 akeys?: Shortcutprop in the shortcut grammar (mod+shift+p), drawn through Kbd'skeysorformatShortcut, with akeyLabelsprop for other keyboards; keep a literal display prop the component already ships.scripts/validate-overlay-listeners.mjsfails 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 byvar(--ds-inline-sign, 1)and importsrc/behaviors/direction.css; switch onlogicalArrowKey(event.key, event.currentTarget)fromsrc/behaviors/direction.tsfor left and right; measure a drag from the inline start withisRtl; ask a popup forstart/end; and give a glyph that points along the reading direction theds-mirror-rtlclass (an icon a consumer names takesmirrorClass(name)). A fictionalGadgetSliderwhose 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.mjsfails the build otherwise; check the component with Storybook's Direction toolbar set to right to left.- Compose the shared primitives; never redraw one — build-enforced. A component that offers a choice renders
CheckboxIndicator,RadioIndicatororSwitchIndicator(exported beside Checkbox, RadioButton and ToggleSwitch) and carries the role, the state and theds-control-hostclass on its own root; a list or menu row marks its selected item withChosenMark(from the Dropdown module); text that is read but not seen takesds-visually-hidden; a header's close is a compact tertiaryCircularButton; a mutually exclusive set of pills is aSegmentedControl. A fictionalGadgetPickerthat wants a tick beside each row renders the checkbox indicator, never acheck_boxglyph or a bordered<span>of its own.scripts/validate-shared-primitives.mjsfails 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'sSANCTIONEDtable. - Give every own prop a JSDoc description — this is build-enforced. That JSDoc is the single source for Storybook's props table, the
.d.tsconsumers get, and the machine-readable prop API the site's/api/mcproute serves to any agent and the site chat'sget_componenttool repeats to visitors (website/src/data/component-api.generated.ts) — so a prop description is public copy strangers' agents and site visitors both read, andscripts/validate-component-api.mjsleak-screens it (a hit there is a leak in the npm tarball too). All three consumers see the same parse: the settings live inscripts/component-docgen.mjs, mirroring.storybook/main.ts.scripts/validate-prop-docs.mjsfails the build on a prop without a description. Two traps it catches:
/** 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:
htmlForonly associates with labelable elements (input, select, textarea, button…). If your control is a composite built from adiv— arole="combobox"trigger, say — pointaria-labelledbyat`${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
asideprop, 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. Abadge?: number | truethat 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. Preferaria-labelledbypointing at the component's own visible heading (ids fromuseId(), never hard-coded, so two instances cannot collide) andaria-describedbyat its subtitle. When there is no visible heading, set a sensible defaultaria-label. - Every default label is overridable, by the consumer's own attribute. Write the default before
{...rest}(<nav aria-label="Main" {...rest}>), so a passedaria-labelwins 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-hiddenwhile it is the invisible one, and a region collapsed or minimised out of view isinertso 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/accessibilitydropped its ARIA census because it read a correct native element as a shortfall. A<table>,<blockquote>or heading needs nothing added, and anaria-labelon a plaindivorspanis 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-bgfailsscripts/validate-action-colour.mjsuntil it is added to that script'sSANCTIONEDtable 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-sizeon the icon element to a step from the--icon-size-*scale (src/tokens/registry.jsonis the list) — neverfont-sizeor raw pixel dimensions on an icon - Transitions/animations compose
--motion-duration-*with--motion-ease-*fromtokens-motion.css— never literal timings like0.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-compactatsize="compact") withbox-sizing: border-boxand 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-dropdownfor an anchored menu,--layer-floatingfor a hover card,--layer-modalfor a dialog;tokens-light.csslists them lowest first), orcalc()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 takesds-allow(stacking)directly above it (scripts/validate-stacking.mjsenforces it). A tooltip takes no layer of its own choosing: it callsuseTopLayerfromsrc/behaviors/useTopLayer.ts, which paints it above every layer, and the same validator fails a tooltip that does not - A viewport
@mediaquery switches at a width fromsrc/tokens/breakpoints.ts(max-width: N, ormin-width: N + 1for the same step seen from above), and a JS query is built from that module'smediaBelow/mediaAbove, never a literal string. A content-driven width outside the set takes ads-allow(breakpoint)directive directly above the query;scripts/validate-breakpoints.mjsfails 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 inscripts/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
GadgetRowthat invents a fourth radius for "a row like the others" is the drift thecomponent-consistency-auditskill 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 insrc/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.mjsfails 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-schemequeries — 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 owndesign.mdspec, never assumed
File 3: ComponentName.stories.tsx
- Import:
import type { Meta, StoryObj } from '@storybook/react-vite' - Meta uses
satisfies Meta<typeof ComponentName>, and setscomponent: ComponentName. The props-contract story finds the component through that field and borrows its props from the meta'sargsor the first story withargs, so a component the meta does not name, or whose stories supply its required props only through arenderfunction, fails as uncovered. When the stories cannot carry the props as args, add aFIXTURESentry insrc/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
StoryObjexport 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
playfunction asserting it —Dialog.stories.tsxis the reference (stories run as tests, so the assertion is CI). End the play in the component's resting state so snapshots stay stable
Pass design QA before registering. Invoke the
design-qaskill 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 storybookcannot cold-start here, because itsprestorybookhook runs thevalidate-registrychain, which correctly fails on the still-unregistered folder — use a Storybook that is already running, or start thestorybook-rawconfiguration in.claude/launch.json(port 6016; it invokes Storybook directly, bypassing theprestorybookhook, 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 insidearia-hiddenorinertregions. A short script in the browser that queriesnav, aside, [role], a, buttonand readsaria-label,aria-labelledby,innerTextandclosest('[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 theaccessibility-auditskill'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 aplayassertion, so that it stays proven in CI.The gate's last half is the API and the family: run the
api-consistencyskill scoped to the new component and its nearest family (its shared folder or registry category), then thecomponent-consistency-auditskill 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.Register the component in
src/components/registry.json— add an object to thecomponentsarray (alphabetical byname):{ "name": "MyComponent", "label": "My component", "slug": "my-component", "description": "One line, ending in a full stop, under 160 characters.", "category": "forms", "client": true }The
descriptionis 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 incontent-design.md.categorymust be one of the registry'scategories— add a new one deliberately rather than inventing one per component. Thelabelhas a build-enforced length ceiling from an unexpected direction:scripts/validate-page-summaries.mjsderives 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.clientmust 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 insrc/components/registry.tsowns 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.mjsruns before every build and fails if the folder is unregistered. Confirm the registration itself withnode scripts/validate-component-registry.mjs— the fullnpm run validate-registrychain stays red by construction until the website page anddesign.mdsection exist (scripts/validate-website-surfaces.mjsrequires 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 thevalidate-registryentry in the rootpackage.json) regenerate every derived surface a new component touches: the README's component count/list, the package barrels (src/index.ts, orsrc/charts.tsif the component imports recharts — the generator routes by detecting the import and fails when the registry entry'srechartsflag 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.mjsthen proves the new file against the meta-schema), the shadcn registry item (website/public/r/<slug>.json, plus itsregistry.jsonindex), the consumer agent skills' component catalogue (references/components.mdin the library skill's folder underwebsite/public/skill/—AGENT_SKILLSinscripts/brand.mjsowns 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 separatechore(generated):commit when the regeneration would bury the real change (both are sanctioned; theshipskill'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 publishedrift-dsAPI — both the barrel and the./components/*deep-import subpath. That wildcard maps.tsxonly. A sibling.tsmodule in the same folder (a hook, a data table, a shader source) reaches consumers through the barrel if the.tsxre-exports it (a value re-export from a.tsxtrips thereact-refresh/only-export-componentslint rule, so it sits under aneslint-disablefor that rule with the reason written beside it, asShaderField.tsxdoes;export typeneeds none), but has no deep-import path of its own until you add one — and a subpath has three homes, not one:SUBPATHSinscripts/package-manifest.mjs(the authoritative record of the ones that exist), the rootpackage.jsonexportsfield it is mirrored into by hand, and the dist entry list invite.lib.config.ts(thenew-tokenskill's step 5 is the same recipe).scripts/validate-package-exports.mjsfails 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.mjschecks the answer either way — an unexported.tssibling must be re-exported by the component or named in that script'sINTERNAL_MODULESlist with a reason. A sibling.tsxis 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 inINTERNAL_PARTSinscripts/package-manifest.mjswith its reason, which closes its deep path with anullexport and keeps it out of the barrel; copysourceExports()into the rootpackage.jsonafter 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 (peerDependenciesMetain the rootpackage.jsonis 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
docOnlyHelpersarray is the authoritative list; note the publicSwatchcomponent is not one of them) goes in the registry'sdocOnlyHelpersarray instead ofcomponents— it gets no showcase page, no barrel export, and doesn't count. Putting it incomponentsfails the build for a missing website page.- 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. Hand off the website work. A component is not done until it has a showcase page: a
page.tsx, apage.module.css, alayout.tsxcontaining exactlyexport const metadata = componentPageMetadata("<slug>");, and a preview entry under its slug inwebsite/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 byscripts/validate-website-surfaces.mjsandscripts/validate-page-titles.mjs. Ask the owner: "Should I add the website documentation page now? (invokes thecomponent-doc-pageskill)" — and whoever does that work must complete every registration above, then runnpm run validate-registry: with the page and thedesign.mdsection in place, this is the first point in the flow where the whole chain can go green.One surface is curated and not build-enforced: 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 iswebsite/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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

