We wrote the same page three times. The hand-written one was the largest.
Plain HTML, hand-written styled-components, and our own design system — measured on the same screen, and the result was not what we expected.
We have the same marketing page in three implementations, all of them on disk:
| What it is | |
|---|---|
| A | one HTML file with inline styles |
| B | Next.js with hand-written styled-components — this is the production site |
| C | our own design system, only its components and tokens |
Three versions of one screen is an unusual thing to have. It’s also the only way to answer a question people argue about constantly and rarely measure: what does a design system actually save you?
Everything below is counted from the code, on the same screen.
The result we did not expect
Characters, comments excluded:
| home page | contact | |
|---|---|---|
| A — plain HTML/CSS | 23,139 | — |
| B — styled-components | 32,149 | 11,293 |
| C — design system | 18,040 | 4,794 |
44% smaller than the hand-written version on the home page. 58% smaller on contact.
The part we had to sit with: B is the biggest of the three. Bigger than raw HTML.
The reason is obvious in hindsight and easy to miss in advance. Before B can write a page, it has to write a container, a section, a heading, buttons, a stat strip, a card — fifteen files reproducing what a box and a text component already do. The plain HTML version never pays that cost because it never pretends to have a system.
A half-built system is more expensive than no system at all. That’s the finding, and it has nothing to do with which library anyone chose.
The number that predicts pain later
Code size is a weak metric. This one isn’t: how many design values are written into the code by hand.
| home page | contact | per 100 lines | |
|---|---|---|---|
| A | 521 | — | 198 |
| B | 197 | 39 | 13 |
| C | 13 | 0 | 1 |
Forty times fewer than the raw HTML. Fifteen times fewer than the hand-written system.
And the thirteen aren’t scattered: three pixel values and ten flex-basis numbers, all in one file, each with a written condition under which it disappears.
What one change actually touches
This is the version of the argument a non-engineer understands.
| change | A | B | C |
|---|---|---|---|
| brand colour | 58 places | 1 variable | 1 token → 3 platforms |
| spacing scale | 373 pixel values | 196 pixel values | 0 |
| add a light theme | rewrite the file | author 48 new variables | already works |
| ship it to mobile | not possible | not possible | 62% of the code goes |
The pattern hiding in that table
Look at row one and row two together, for B.
B tokenized its colours properly — 48 custom variables, 276 usages. On colour it is genuinely equal to the design system.
B did not tokenize its metrics. 196 hand-written pixel values.
That is not ignorance. It’s a pattern, and we’d bet money it’s in your codebase too: you tokenize what hurts immediately, and you don’t tokenize what hurts in a year.
A wrong brand colour is visible in a screenshot and someone complains the same day. Inconsistent spacing is invisible for months and then becomes a redesign nobody can schedule.
The relevant difference isn’t that one team was more disciplined. It’s that in the design system the choice isn’t available: a padding prop physically will not accept a pixel value.
Two sources of truth, quietly diverging
There’s a second thing in B worth naming, because it’s the failure mode of adopting a system halfway.
B overrides 69 of 212 design system variables in its own theme file. The other 143 were never overridden — so they follow the system, while the 69 follow the local file.
Nobody decided that. It’s just where the work stopped. And it means half the surface tracks one source and half tracks another, with no marker anywhere saying which is which.
What the compiler catches
We wrote ten mistakes a new developer makes in their first week and ran them through both approaches.
In the design system, all ten are compile errors at the call site:
<Box padding="16px" /> // string is not assignable to a space token
<Box gap={7.5} /> // 7.5 is not a step on the scale
<Box background="teal" /> // not a background token
<Box maxWidth="1200px" /> // not one of the seven widths
<Box marginTop={-3} /> // negative steps are strings: "-3"
<Text variant="huge" /> // not a text variant
<Text tone="brand" /> // not a tone
<Text align="left" /> // it's "start", not "left"
<Box style={{ padding: 16 }} /> // there is no style prop
<Box border="default" /> // border is a boolean hereTen out of ten.
The same mistakes, written as styled-components:
const A = styled.div`
padding: 16px;
gap: 15px;
background: teal;
max-width: 1200px;
margin-top: -3px;
font-size: 42px;
color: #brand;
text-align: left;
paddin: 16px; /* misspelled property */
colour: red; /* misspelled property */
`;Zero out of ten. All of it compiles.
And look at the last two. paddin and colour are accepted and silently do nothing. No error, no warning, no clue — just an element that isn’t what its author thought it was.
A custom lint rule in our own repo catches three of the ten, and only where a token exists for the value. In the production site that rule doesn’t exist, so there it’s zero.
For someone new, that’s the difference between “the editor won’t let me get it wrong” and “you’ll find out when someone notices.”
What you have to keep in your head
| has to remember | from where | |
|---|---|---|
| B | 77 distinct pixel values | the code; written down nowhere |
| B | 43 theme variables | one CSS file |
| B | 30 distinct font-size declarations | scattered |
| B | 26 custom components | undocumented |
| C | closed sets | autocomplete offers them |
The design system’s sets are small and finite: nine spacing steps, six radii, fourteen text variants, eleven tones, twelve backgrounds, five shadows, seven widths. Font size isn’t available at all — you pick a variant, not a number.
The difference isn’t “less to learn.” It’s the difference between what you memorize and what the editor hands you.
Documentation that cannot go stale
| design system | hand-written | |
|---|---|---|
| registered components | 82 | — |
| live fixtures | 63 | — |
| static catalog pages | 81 | 0 |
| a check that enforces it | yes | none |
The catalog check runs as part of verification and fails if a component has no entry, no fixture, or if the generated props are out of date. Documentation can’t fall behind the code, because the build stops.
That’s the part we’d argue is worth more than the code savings. Undocumented components aren’t reused; they’re re-implemented. Which is how you get twenty-six of them.
The second page
The first page of anything pays for the setup. So we built a second one — a contact page — to see what the system was worth once it existed.
It took four minutes and forty-nine seconds, from the first line to a green verification run, including a mistake we made and fixed.
We’re not going to put that number next to human effort, and we want to be explicit about why. It’s an agent’s time, and the baseline isn’t clean either — the production site was itself built with an agent, over two and a half days and thirty-eight commits. Git measures calendar time, not work.
What can be claimed is where the speed came from:
| first page | second page | |
|---|---|---|
| navigation, footer | invented | imported |
| grid | invented | imported |
| tone rules | derived from contrast | inherited |
| form | — | four components, assembled |
Only two things were new: a column ratio, and the form. Everything else was an import.
And the chrome was extracted into its own file at that point — when a second consumer appeared, not before. Extracting before the second consumer is guessing at what will be shared.
What we’re not claiming
- None of this ran on a device or simulator. The mobile figure rests on which host components the code emits, not on a launched app.
- No build-time measurement comparable to human work. See above.
- The contact form has no transport. Nothing is sent anywhere, and it says so both in the code and on screen.
- The three versions are not functionally identical. The original has dropdown menus and a burger; the rebuild deliberately doesn’t.
- The contact comparison carries a caveat. Their form has a captcha and a server action we didn’t build. Even after generously removing sixty lines from their side, it’s 312 against 137.
What we’d take from this
Half a system costs more than none. The hand-written version is the largest of the three, and every line of that overhead went into rebuilding primitives that already existed somewhere.
Tokenize the things that don’t hurt yet. Colour gets tokenized everywhere, because a wrong colour is visible today. Spacing rarely does, because wrong spacing is invisible until it’s a redesign.
The real saving isn’t characters, it’s what one change touches. Fifty-eight places against one. That number is the whole argument, and it doesn’t need a chart.
This came out of building a cross-platform design system covering Next.js, React Native and a Vite SPA. Read the case study →