Skip to main content
Bartek Czyż
← writing
13 min read
  • design system
  • design
  • react
  • vanilla-extract

Design system: making the wrong thing impossible

by Bartek

Design system: making the wrong thing impossible
Photo by Anna Mysłowska-Kiczek on Unsplash

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

Here is a thing that happens in every product team, and it is nobody’s fault.

A designer is deep in a flow at 5pm. They need a card that is almost the one in the design system, except the label needs to be slightly quieter, so they detach the instance and drop the text colour down a notch. Or they need a summary row, do not realise a summary row already exists under a name they would never have searched for, and build a new one from scratch with 20px padding instead of 16.

Neither of these is a design decision. They are the residue of a busy afternoon. But they arrive in a ticket, and a developer implements them faithfully, because implementing the design is the job. Six months and forty tickets later, the product has three greys that are almost the same grey, and nobody can say which one is correct.

I spent three years building a design system for a fintech product, and this is the failure mode I cared about most. Performance mattered. Bundle size mattered. Neither of them worried me the way the slow drift of a thousand small, individually reasonable decisions did.

The usual answer is documentation and code review. We wrote plenty of both. What I want to show you is what actually worked, and I have three years of data on which is which.

The principle: don’t document it, delete it

The rule I ended up designing around is simple to state:

If a developer should never write it, they should not be able to type it.

Not “should be told not to”. Not “will be caught in review”. Should not compile.

This sounds authoritarian, and people react to it that way at first. What changes their mind is the arithmetic: a design system with 93 colour tokens does not offer a developer 93 choices. It offers roughly three, and 90 opportunities to be subtly wrong. Removing the 107 takes no power away from developers. It takes a decision off their plate that was never theirs to make.

Here is how that was built, in four layers.

Layer 1: value sets with no room in them

The styling layer is vanilla-extract with Sprinkles, which generates atomic CSS classes from a set of properties you declare up front. The important part is not the atomic CSS. It is that you declare the entire set of legal values, and nothing outside it exists:

typescript
const layout = defineProperties({
  properties: {
    padding: {
      4: '0.25rem',
      8: '0.5rem',
      12: '0.75rem',
      16: '1rem',
      24: '1.5rem',
    },
    width: {
      auto: 'auto',
      '100%': '100%',
      'fit-content': 'fit-content',
      'max-content': 'max-content',
    },
  },
});

Our real spacing scale has fifteen steps. The point is what is missing. There is no padding={13}, because 13 is not on the scale. More interestingly, there is no arbitrary width anywhere in the API: width takes one of five named values, and none of them is a measurement. You cannot say “312 pixels wide” through this API, in any component, anywhere in the product. If a design calls for a 312px card, that is a conversation with the designer, not a prop.

One deliberate omission is worth calling out. color is not in the main property set. Layout primitives can set a background, but they cannot set a text colour, because text colour belongs to the typography component and nowhere else. That single exclusion removed an entire category of drift.

Layer 2: tokens as a contract

Underneath sits a theme contract: a declaration of every token name with no values attached.

typescript
export const theme = createThemeContract({
  color: {
    'surface.primary': null,
    'surface.brand': null,
    'surface.brand-hover': null,
    'text.body': null,
    'text.body-muted': null,
    'text.action-brand': null,
    // ...roughly 93 of these
  },
});

Every null becomes a CSS custom property. The actual hex values live in exactly two files, one per theme, and nowhere else in a hundred thousand lines of code. Because the contract is a TypeScript object, the token names are a union type. Referring to a token that does not exist is a compile error, and renaming a token surfaces every use of it immediately.

This is the layer that has paid for itself most obviously, in two ways I did not have to argue for.

The first is white labelling. We now run several white-label deployments, each with its own theme. A theme is an implementation of the contract, so adding one means supplying values for names that already exist, and the compiler tells you the moment you have missed one. There is a runtime override path too, for tenants that need to adjust a handful of tokens without a full theme. In our previous design system, built on styled-components, this would have meant touching a theme file per component, and I do not think anyone would have volunteered.

The second is the rebrand. We recently rebranded the product. From the design system’s side it was largely a matter of changing values in the theme files, because no component in the library knows what colour it is. They know they use surface.brand. The interesting work was in the visual regression suite, which is a story for another post.

Typing the token set answers “is this a real colour?”. It does not answer the question that actually matters, which is “is this a real colour here?”.

text.link is a perfectly valid token. Using it on a 32px page heading is a mistake rather than a decision, and no amount of token typing catches it, because both halves are individually legal.

So the typography component does not accept a colour. It accepts a colour per variant:

typescript
/** Like Pick, but U must actually exist in T. */
type SubsetOf<T, U extends T> = U;

interface HeadingLarge {
  type: 'heading.large';
  as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
  bold?: never;
  color?: SubsetOf<ColorToken, 'text.body' | 'text.body-muted' | 'text.action-brand'>;
}

interface BodyXSmall {
  type: 'body.x-small';
  as?: 'span' | 'p' | 'label' | 'code' | 'pre';
  bold?: true;
  color?: SubsetOf<ColorToken, 'text.body' | 'text.body-muted' | 'text.brand'>;
}

export type TextProps = { children: ReactNode } & (
  | HeadingLarge
  | BodyXSmall
  // ...twelve more variants
);

Fourteen variants, each declaring its own legal surface. heading.large offers three of the roughly 93 colour tokens in the system. It also declares bold?: never, because the variant is already semibold and there is no such thing as a bolder one.

My favourite detail is bold?: true on the smallest body variant. Not boolean. That variant only exists in its bold form in the design, so bold may be present or absent, but writing bold={false} is a type error. The type encodes which values are allowed, and beyond that, which combinations were ever designed.

The SubsetOf helper is four words long and does the load-bearing work. Because U extends T, an allowlist entry that stops matching a real token name fails to compile. Rename a token and every allowlist referencing it breaks loudly, instead of silently permitting a string that no longer means anything.

Layer 4: lint for what types cannot see

Types cannot see inside a string. So one rule sits outside them, applying to every TypeScript file in the package:

typescript
'no-restricted-syntax': ['error',
  {
    selector: 'Literal[value=/px/]',
    message: 'Usage of "px" is not allowed, use "rem" directly or call "pxToRem" instead.',
  },
  {
    selector: 'TemplateElement[value.raw=/px/]',
    message: 'Usage of "px" is not allowed, use "rem" directly or call "pxToRem" instead.',
  },
];

Blunt, and it catches a few false positives in comments. It also means that in three years, not one pixel value reached the styling layer of the design system, because it is an error and CI runs it on every pull request.

Put together, the four layers produce a component API where the wrong thing is unavailable rather than discouraged:

tsx
<Text type="heading.large">Balance</Text>
<Text type="heading.large" color="text.body-muted">Balance</Text>

// @ts-expect-error 'text.link' is a real token, but it isn't offered on a large heading
<Text type="heading.large" color="text.link">Balance</Text>

// @ts-expect-error headings carry their own weight
<Text type="heading.large" bold>Balance</Text>

// @ts-expect-error 13 is not on the spacing scale
<Box padding={13} />

// @ts-expect-error width takes a named value, never a measurement
<Box width="312px" />

What actually survived

Here is the part I did not expect, and the reason I think this is worth writing down rather than just an aesthetic preference.

We had two kinds of rules. Some were enforced by a tool: the type checker, or a lint rule that fails the build. Others were enforced by people, written in the contributor guide, in the documentation template, repeated in code review.

Three years later, I went and counted.

The tool-enforced rules held completely. No pixel values, because the lint rule is an error. No off-scale spacing, no unavailable colour on a heading, because those do not compile. The compliance rate is 100% by construction, which is exactly the point: there is no path to non-compliance that runs through a tired Friday afternoon.

The human-enforced rules did not fare so well:

RuleWhere it livesCompliance
No raw pixel valuesLint rule (error)100%
Only tokens from the variant’s allowlistType system100%
Every component is documentedContributor guide43 of 47
Docs include “when to use / when not to”Documentation template10 of 67
Every story links to its Figma frameContributor guide1 of 105

That last row is my favourite, and I say that as the person who wrote the rule. “Every story must link to Figma” is a good rule. It survived in one file out of a hundred and five.

Nobody was lazy. Twenty-odd people contributed to this library over three years, most of them excellent, and every one of them was shipping product under a deadline. The rules that survived were not the important ones or the well-argued ones. They were the ones you could not skip.

That is the whole argument, and it generalises past design systems: a rule that depends on a human remembering it is not a rule, it is a preference with good PR. If something genuinely matters, spend the effort to make it unrepresentable. If it is not worth that effort, be honest that it is advice.

What it cost

I would be selling you something if I stopped there.

Type errors that read like abuse. A discriminated union of fourteen variants produces spectacular error messages when you get one wrong. TypeScript reports the failure against every member of the union, so a misplaced prop can generate forty lines about variants you were not using. Newcomers hit this in their first week and it is genuinely off-putting. It is the single biggest ergonomic cost and I have not solved it.

The escape hatch has to exist. You cannot close a system completely and still ship. Ours is that a developer can always write a plain stylesheet file and pass a class name. That is deliberate: it is a visible, reviewable act that shows up in a diff, instead of a prop that slips through unnoticed. But it is a hatch, and it is used.

Not everything fits in the types. Our button has two tone values that exist for internal use only, marked with a code comment and a line in the docs. Nothing stops a product developer typing them. Some constraints need a second package boundary to express properly, and we did not build one.

Zero-runtime CSS does not escape the cascade. This one cost a day and taught me the most. Buttons rendered correctly in one application and transparent in another, from the same version of the library. The fix, and the comment now sitting above it in our codebase:

Hoisted into a && (self-doubled) selector so the local CSS variable assignments win at specificity (0,2,0) over buttonReset’s background-color: unset and border-style: none (specificity 0,1,0). Equal-specificity cascade left these brittle to CSS chunk load order: in apps where the buttonReset chunk happened to land after the recipe chunk, the action button rendered transparent and borderless.

Static extraction gives you a lot. It does not give you immunity from the fact that CSS is ordered, and that a consuming application decides that order.

Why this stack

We chose vanilla-extract in early 2023, after trying styled-components with styled-system, Tailwind, and Chakra. Three things pushed us away from styled-components, which is what our previous design system was built on:

  1. Typing was weak where it mattered. CSS inside a template literal is a string. No autocomplete, no checking, no rename support.
  2. Token and theme management was a mess. Every component needed its own theme file to reach token values, and keeping them coherent was constant, thankless work. The white labels and the rebrand described above are the two jobs that would have hurt most.
  3. Runtime cost. Generating styles in the browser was showing up in our performance numbers.

Tailwind was the closest call, and the reason it lost is specific to the goal in this post. Tailwind’s constraint is subtractive and opt-in: everything is permitted by default, and you bolt on a lint rule to take things away. There is no supported way to switch off arbitrary values like w-[13px]. The relevant ESLint rule, no-arbitrary-value, exists and does the job, but it is off by default and it only sees the files ESLint parses. GitLab, doing exactly this, ended up needing a matching Rubocop rule for their HAML templates. Sprinkles inverts that: nothing exists unless declared, the checker is the compiler rather than a separate pass, and there is no per-language coverage gap.

The obvious counter, which I think is fair: lint is adjustable and types are not. Tailwind lets you permit arbitrary spacing while banning arbitrary colours in one config line. We cannot do that without restructuring types.

Timing deserves a mention too. Panda CSS now ships strictTokens and strictPropertyValues, which do at config level much of what I hand-built, along with the ability to switch style props off entirely. If I were starting today I would evaluate it seriously. It was not a production option in April 2023. The one thing I would still argue for our approach: Panda always keeps a [...] escape hatch that types accept silently, and the absence of a silent escape hatch was the entire design goal.

There is also a claim I could make here that would be wrong. styled-components entered maintenance mode in March 2025, two years after we made this decision, and it has since resumed shipping with a v6.4 release that improves both theming and performance. I did not see that coming, because it had not happened yet. The three reasons above stand on their own. The timing was luck.

The payoff I wasn’t planning for

Effectively all of this was built by hand. The first AI instruction files landed in our repository in March 2025, twenty-three months after the project started and fourteen months after the real build began. By then the tokens, the typography scale, the icon pipeline, the documentation site and most of the component library already existed.

When we did start using AI assistants seriously, they were immediately good at working in this codebase, and the reason was not mysterious. It was the same reason the constraints work on people. A model generating a plausible-looking <Text type="heading.large" color="text.link"> gets exactly the answer a human gets: it does not compile. The constraints built to stop a tired developer implementing a 5pm design mistake stop a language model doing the identical thing, for the identical reason. Neither of them has to know the rule. The rule is in the shape of the API.

There is a lot of writing right now about making codebases AI-friendly, and most of it is about writing better instruction files. I think that has it backwards. Instruction files are the human-enforced tier, and the table above shows what happens to that tier over three years. The durable version is a system where the wrong thing does not typecheck, whoever is doing the typing.

We spent two years accidentally writing the specification. It turned out to be the prompt.

Next in this series: what happens when changing two theme files moves every screen in the product, and how you prove it moved the way you intended.

Discussion