Skip to main content
Bartek Czyż
← writing
8 min read
  • Design System
  • Documentation
  • Developer Experience
  • Typescript
  • React

Design system: documentation that can't rot

by Bartek

Design system: documentation that can't rot
Photo by Diego Marín on Unsplash

Part 2 of a five part series on building a design system.

Every engineer has had this experience. You look up a component in the internal docs, copy the example, and it does not work. The prop was renamed eight months ago. The example was written against a version that no longer exists. Somebody changed the component and did not update the page, because updating the page is a separate act of will performed at the end of a long day.

The usual diagnosis is that people are lazy or the culture is bad. I do not think that is right. Documentation rots for a structural reason: it is a second copy of the truth, and second copies drift.

Think about how component documentation is usually put together. The props are declared in TypeScript, and then they are written out again by hand in a markdown table. That is two sources for one fact. One of them is enforced by a compiler and the other is enforced by hope, and there is no amount of discipline that makes hope competitive with a compiler over three years.

So when we built our design system, I built the documentation infrastructure before there were any components to document. That felt ridiculous at the time. In February 2024 there was a system that generated props tables from TypeScript types, and no props. It is the reason the documentation is still accurate today.

Generate what can be generated

The props table is the part of component documentation that goes stale fastest and matters most, so it is the part we never write.

The documentation site walks the component source, runs it through react-docgen-typescript, and emits a JSON file of every component’s props, types, defaults and JSDoc comments. The page renders that. Nobody maintains it. It runs on every dev start and every build.

tsx
const parser = docgen.withCustomConfig(tsconfigPath, {
  shouldExtractValuesFromUnion: true,
  shouldRemoveUndefinedFromOptional: true,
  propFilter: (prop) => {
    // Keep our props and a couple of useful natives, drop the
    // several hundred inherited DOM attributes nobody wants to read.
    if (!prop.parent) return true;
    if (['ref', 'as'].includes(prop.name)) return true;
    if (prop.parent.fileName.includes('csstype')) return true;
    return !prop.parent.fileName.includes('node_modules');
  },
});

That propFilter is small and load-bearing. Without it, every component’s documentation is three hundred rows of inherited HTML attributes with the six props you care about buried somewhere inside.

shouldExtractValuesFromUnion is the setting that made this worth doing. Our typography component constrains which colour tokens each variant may use, so heading.large accepts three of the design system’s ninety-three colours. That constraint lives in a TypeScript union, and this flag means the union expands in the documentation automatically. Nobody wrote “this variant accepts these three colours” in prose. The rule and its documentation are the same object, so they cannot disagree.

This is the general principle. Where documentation is derivable from code, derive it, and the question of keeping it current disappears rather than being solved.

Make the rest fail the build

Props tables cover the “what”. Examples cover the “how”, and examples are not derivable, so they need a different trick.

The obvious approach is a fenced code block. It is also the worst one, because a code block is a string. It cannot be type checked, it cannot be run, and it will be wrong within a year.

So our examples are not strings. Each component has a real .example.tsx file next to it, exporting named examples, and the documentation references them by name:

markdown
```jsx component=buttons sourceFile=button.example.tsx example=Default
// title: Default usage
```

At build time that resolves to the actual exported component and renders it. The consequences are the good kind. An example that references a removed prop fails type checking. An example that imports something that no longer exists fails the build. The examples are covered by the same lint and compiler passes as the library, because they are the library’s code.

They are also live. The rendered example is editable in the page, so a developer evaluating a component can change a prop and watch it react before deciding to use it. That turned out to matter more than I expected for adoption. Reading that a component has a busy state is different from setting busy and seeing what happens to the label.

Token pages work the same way. The colour page is not a hand-maintained table of hex values, which would be the single most rot-prone artefact imaginable. It is a component that reads the live tokens and renders them:

markdown
# Colours

<Colours />

Add a token, and it appears on the page. Rename one, and the page follows. Delete one, and it goes.

Build the docs with the thing you are documenting

The documentation site is a separate application that depends on the design system as a workspace package. It is built with the components it documents. Its buttons are our Button. Its layout is our Stack and Box. Its type is our Text.

This is dogfooding, and the usual argument for it is cultural, something about empathy for your users. The real value was more mechanical: the documentation site was our first consumer, and it was a demanding one.

It hit problems before any product team did. It needed a component composition we had not anticipated, and the gap showed up in our own repository instead of in a bug report. It needed the design system to work in a Next.js application with a different bundler configuration from the main product, which surfaced integration problems while they were cheap. When something in the library was awkward to use, I hit that awkwardness personally, in the same week I had shipped it.

The alternative is documenting a library you do not use, which is how you end up with a documentation site full of components that technically work and are unpleasant to hold.

Three surfaces, three audiences

We ended up with three ways to look at the same library, which sounds like duplication and is not.

Storybook is for developers working on the design system, and it doubles as the corpus for visual regression testing, so the stories earn their keep twice. We do not write a story per variant, which gets unmanageable fast. Instead there is a Cartesian helper: you hand it a component, the prop values you care about and a list of pseudo-selectors, and it renders the entire matrix as one labelled grid, hover and focus states included. Dozens of combinations on a single page. The next post explains how that works and why it matters for screenshot counts. The ratio is still worth stating plainly: our Button component is about 100 lines, and its stories are about 700.

Playroom is a sandbox that renders JSX live across several viewport widths at once. It is the one designers actually open. Being able to hand someone a URL where they can assemble real components at mobile, tablet and desktop simultaneously changed the nature of design conversations, because the thing being discussed is the real thing rather than a picture of it.

The documentation site is for people consuming the library: what exists, which prop does what, when to use this instead of that.

What did not survive

I would be misrepresenting this if I only showed you the parts that worked.

Our documentation template mandates a “when to use, when not to use” section for every component. It is arguably the most valuable section, because the most common design system failure is a developer using the wrong component correctly. Ten of our sixty-seven documentation files have it.

Every component is supposed to be documented. Forty-three of forty-seven are, which is decent, and the four that are not are the four you would guess: the ones that felt too obvious to bother with.

The pattern is not subtle. The generated documentation is at a hundred percent, because it is a build step. The hand-written documentation is wherever people’s attention left it, because it is a request. Same team, same repository, same three years. The difference is entirely whether a machine or a person was responsible for keeping it true.

That is the whole thesis, and the next post pushes it much further: if something has to stay correct, do not ask a human to keep it correct.

The part that paid off later

There is a reason to care about all this that did not exist when we built it.

When we started using AI assistants on this codebase in 2025, they were good at it immediately. Better than on our older code, by a lot. Some of that is the type system, which is the next post’s argument. But a real part of it was documentation, and specifically the fact that our documentation was mechanically true.

A model reading a props table generated from the types is reading the types. An assistant that finds an example is finding a file that compiles. There was no stale page telling it about a prop we removed in 2024, because there are very few hand-written pages at all. The naming is consistent because the naming conventions were written down and enforced early, so size means the same thing on every component that has it.

We did not do any of this to make a language model’s life easier. We did it because we did not want to maintain documentation. It turns out those are close to the same problem: both a new developer and a language model are trying to work out what is true about your system from what your system says about itself, and both of them are badly served by a second copy of the truth that drifted.

Next in this series: the constraints themselves, and the three years of data on which rules survived.

Discussion