Documenting a component
Every component in the design system is four files. Nothing is written twice, and nothing that can be derived is typed by hand.
| File | Holds | Read by |
|---|---|---|
<name>.tsx |
One paragraph of what it is; a docblock per prop | Editor hover, and the props table |
<name>.stories.tsx |
The scenes and their play functions | The canvases, and the browser test run |
<name>.mdx |
The long-form page | The catalogue |
<name>.spec.tsx |
The vendoring contract, and what a story cannot reach | The jsdom test run |
The source is the only place a prop is described
The props table is built from the component’s own docblocks by the catalogue’s extractor. A
story file writes no argTypes for documentation: that is how a description and a signature
drift apart. Every prop that appears in the table is declared on the component’s own
interface, even an inherited one, because the extractor does not follow extends into
node_modules.
export interface ButtonProps extends ButtonPrimitive.Props { /** * Sets the height and the padding. The four `icon*` sizes are square and render no text, * so they carry no accessible name until `aria-label` gives them one. * * @category Appearance * @default default */ size?: ButtonSize | undefined}@category groups the row; @default states the default the library applies, which is not
always the one this file writes. Both are lifted out of the description.
Seven rules the extractor depends on:
- Every optional prop is
T | undefined:exactOptionalPropertyTypesis on, and the extractor drops that member before deciding the control. - A union fits on one line where it can; the extractor prints the source text.
- No
VariantProps<typeof recipe>: a mapped type yields nothing. The union is declared and the recipe map is keyed by it, so a missing variant is a compile error. - No discriminated unions for props: the table comes out empty. One interface, and the constraint documented.
- The signature is annotated with the interface itself, never
Readonly<XProps>. - A type is written out, never
X.Props['y']: the type column prints the annotation verbatim. - A component rendering through
useRendercarries@componentin its docblock, or it has no table at all.
The component’s docblock is printed as the page’s description with every block tag stripped;
@param and @returns stay in the source for the editor and the lint rule.
Controls
Every prop that can be driven from the panel is. The Playground is the page’s first story and the one the controls point at, so its args seed the panel. The extractor decides the control from the type:
| Type | Control |
|---|---|
| A union of literals | Select or radio, offering exactly those members |
boolean, number, string |
Checkbox, number, text |
ReactNode |
Text, not the JSON editor |
| Anything callable | None; a spy is seeded instead |
argTypes in a story file configures a control and never describes a prop, and only where a
prop cannot be typed into a field: an element, or an arg the story invents. A comment says
which. Event handlers need no fn(): the preview seeds a spy for every on[A-Z] prop the
table names.
Stories
A story file does three jobs at once. Every example is a canvas on the page, under the section that argues for it. The Playground is where a consumer tries the API before adopting it. Every story runs in Chromium with its play function and an accessibility pass. A story that only renders a variant does none of the three.
The file
The file reads in the order the page does: imports; fixtures grouped per domain, each group under a comment naming the domain or the edge it exists to show; the meta; the Playground; the examples.
- The meta is a literal object, because the indexer reads the file without evaluating it. It
carries no
title, which the path derives, and noautodocstag, because the MDX file is the page. - The first export is
Playground. Every other story spreadsexamplefrom the catalogue’s authoring kit, which drops it from the sidebar, turns its controls off and pads the canvas. The sidebar carries exactly two entries, Docs and Playground. - Fixtures move to
<name>.fixtures.tswhen they crowd out the scenes: more than about sixty lines before the first story, or a story file past three hundred lines. The fixtures file exports the data and any component a story composes from it; the story file keeps the scenes.
Fixtures
Every example is a different world. The house domains are payments and billing, healthcare, logistics and freight, CI and developer tooling, HR and people, e-commerce, observability and SRE, legal and compliance, education, media, energy and utilities, and the public sector; a file rotates through them, one per example.
- Examples differ visibly, not only in their strings: state, count, context, language, control type. Two canvases that look the same are one example.
- A story that sets neither
argsnorrenderinherits the Playground’s canvas and draws it a second time under a different heading, which is a defect. - Values are realistic: names from more than one locale, amounts with a currency and a
plausible magnitude, identifiers that look like identifiers, dates that could be real.
Never
foo,Item 1or lorem ipsum. An edge story gets edge data, the cycle, the vacancy, the zero-byte file, and the fixture comment names the edge. - The Playground’s fixture is the home domain: the component’s most typical production use, fully populated, because it doubles as the editable example in the panel.
The examples
The export name is the claim (NoRowsMeansNoDataOption); the docblock above it is the
caption the page renders; the argument for the scene belongs in the page’s section. Name,
fixture and play assertion agree.
| Example | Present when |
|---|---|
| One per claim | Always: this is the example set |
| States grid | The component has a genuine variant by state grid; pseudo-states are forced through the kit |
| Keyboard | There is a keyboard map; driven by userEvent.tab() and keyboard() |
| Empty | The component shows data; the empty copy is a prop, shown and asserted |
| Bad data | The component defends against malformed input |
| Controlled | The component has controlled state; the round-trip is wired inline, with a mirror the play asserts against |
| RTL | Always |
| Localised | The component has label props or English defaults; Dutch is the house choice |
| Integration | The component ships a documented pairing; shown live and asserted through the partner |
| Escape hatch | The component exports parts for recomposition; one story rebuilds it from them |
A grid comes from the authoring kit (Grid, StateGrid, forcedBy, pseudo), and the
lists it iterates are derived from the component itself, so a variant added to the recipe
cannot be missing from the page. A provider that renders no element has no stories; its
effect is measured in the stories of what it affects, and its page says where.
The RTL story measures something the component owns: which side an icon sits on, which
gutter a check mark moves to, whether a rail sits at the right edge, that a plot keeps its
direction while its caption flips. direction === 'rtl' on the root proves only that the
harness works. Where nothing about the component mirrors, the story says so and measures the
thing that stays put. An LTR and RTL pair is the one sanctioned shared render, because the
pair’s claim is that one markup renders both ways.
The Playground
- Every prop that can be edited can be edited: text props get text controls,
classNameincluded; enums get a select; data props get an object control seeded with a full, realistic value. - Editing never breaks the story. The object control round-trips JSON, so function fields are stripped on the first edit; the render re-attaches them after the spread.
- JSX and functions never go through
args: they cross the manager channel and are mangled on the first control change. Children and slot JSX are written inrender;argscarries scalars and JSON. - Every callback is inspectable in the Actions panel.
- Every visible control changes the output; nothing is inert, and an object control is never seeded with an empty array.
- No story-only args: they document props the component does not have.
- The Playground’s play asserts what holds for every value of every control, derived from
args, never from a literal a control can change.
The code panel
Each story’s real source is what a reader recreates the usage from. State wiring is written
inline in each story’s render, in a named function, with handlers composing args.onX?.()
so the Actions panel stays loud; the duplication across stories is deliberate, and hoisted
wiring harnesses are not used. Shared story-local components exist only for what a consumer
would also write, a card renderer or a fixture factory. A story whose play asserts state
renders a state mirror as text under the component, so the play asserts text equality and
a reader sees the state at a glance.
Play functions
A play function is the story’s assertions, and it runs in a real browser: behaviour, interaction, layout measurement and accessibility live here rather than in the spec.
- Queries go by role and accessible name first; the query is itself an accessibility
assertion.
data-slotthroughquerySelectoronly for what has no role, with a comment saying so. - Expectations derive from
args, never from a literal, which fails the moment somebody types in the panel. - No assertion is guarded with
if: an assertion that does not run reads like one that passed. - A visual claim is measured with
getBoundingClientRect, not read from a class name. - Every
expectis awaited:findBy*for what mounts async,waitForafter a state change or an exit animation, never a fixed wait. Portalled content is queried on the document, scoped to the popup carryingdata-open. - A comment names the failure each assertion pins; it is read as documentation when the test goes red.
- An accessibility rule exemption carries its justification on the parameters object.
- A play that drives the component is wrapped in
interactive()from the kit: the catalogue plays every story a docs page mounts, so without the wrapper six sheets open the moment the page is opened. A play that only measures what is already on screen is not wrapped.
Specs
The stories cover behaviour and accessibility in a browser. A spec holds what they cannot:
the vendoring contract (the slot, the class merge order, the ref, the prop spread); recipe
coverage, every documented variant producing its own classes; paths a story cannot reach, an
error event or a rerender with a new prop; and what the component promises not to do, no
role, no classes, no announcement. A spec opens with the same header, so a reader knows where
the rest of the tests are, and never runs a story.
The page
<Meta of={Stories} />, <Title />, <Description />, <Primary />, <Controls of={…} />,
then one ## section per behaviour, each followed by the canvas that demonstrates it.
- The description comes from the docblock and is not restated.
- Every non-Playground story has a section that argues for it, and a canvas names its story by export, so a renamed story is renamed on the page too.
- Standard sections, in this order where they apply: the behaviours, Usage, Exports, the parts table, When to reach for something else, Keyboard, Accessibility, Do, Don’t, Deviations from upstream.
- A family’s parts are one
## Every partsection with a bold lead-in per part and its<ArgTypes>; never a###per part, which buries the behaviours in the table of contents. - Accessibility says what is handled, then a table of what the caller must pass.
- A usage snippet is a component with its import from the real package, never a bare expression.
The package holding the page declares the docs addon itself: every workspace package has its
own node_modules, so an undeclared block import resolves nowhere, and only the catalogue
build reports it.
Deviations from upstream
Where a vendored component was changed, the change is documented under
## Deviations from upstream, and nowhere else: what upstream does, what this does, and what
breaks without the change. Upstream is the only party named.
Styling against a state
Base UI writes a boolean state as an attribute that is present or absent, data-open,
data-checked, data-disabled, and orientation with a value. The theme contract defines the
variants a component may write against: data-open:, data-closed:, data-checked:,
data-unchecked:, data-selected:, data-disabled:, data-active:, data-horizontal:,
data-vertical:. A component writing a state attribute of its own follows the same
convention, data-inset={inset ? '' : undefined}: React writes a boolean false as the
string "false", and a presence selector still matches it.
What the catalogue guarantees
Set once in the preview, so no story has to think about it:
- A click stays in the catalogue. A plain click on an anchor is stopped in the capture phase; a modified click is left alone. A form submission is stopped the same way, and a story that demonstrates submission still receives the event.
- Direction is scoped to the canvas on a docs page and to the document in a story view, and both render a direction provider the primitives read.
- axe runs on every story at error level, which is why no spec calls it.
- The code panel shows the markup, not the story object.