A half-built design system made our page bigger than plain HTML
I measured one page in three versions: hand-written styled-components came to 32,149 characters, plain HTML to 23,139 and our design system to 18,040.
Our marketing page exists in three implementations, and all three are on disk:
| What it is | |
|---|---|
| A | one HTML file with inline styles |
| B | the production site: Next.js with hand-written styled-components |
| C | our own design system, only its components and tokens |
Having three versions of one screen is unusual. It is also the only way to answer a question people argue about all the time and rarely measure: what does a design system save you?
Everything below was counted from the code of that same screen.
The hand-written version has the most code
Characters, not counting comments:
| home page | contact | |
|---|---|---|
| A: plain HTML/CSS | 23,139 | — |
| B: styled-components | 32,149 | 11,293 |
| C: design system | 18,040 | 4,794 |
The design system version is 44% smaller than the hand-written one on the home page and 58% smaller on the contact page.
I did not expect B to be the biggest of the three, bigger even than the raw HTML.
The reason is obvious in hindsight and easy to miss in advance. Before B has a single page, it needs its own container, section, heading, buttons, stat strip and card: fifteen files that reproduce what a box and a text component already do. The plain HTML version never pays that cost, because it doesn't attempt a system.
So a half-built system costs more than no system at all, whichever library it is built on.
Design values written by hand
Code size is a weak metric. A count that says more about later work is 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 |
C has forty times fewer of them than the raw HTML and fifteen times fewer than the hand-written system.
The thirteen in C aren't scattered, either. They are three pixel values and ten flex-basis numbers, all in one file, and each has a written condition under which it goes away.
How many places one change touches
This is the comparison that someone who isn't an engineer can follow.
| change | A | B | C |
|---|---|---|---|
| brand color | 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 carries over |
Changing the brand color means editing 58 places in the plain HTML version and one token in the design system, which then reaches all three platforms. I'd count that as a bigger saving than the characters.
B tokenized its colors and not its spacing
Look at the first two rows for B.
B tokenized its colors properly, with 48 custom variables and 276 usages. On color it is as good as the design system.
It did not tokenize its metrics, and it has 196 hand-written pixel values.
This is a common pattern, and I'd bet it's in your codebase too: teams tokenize what hurts right away and leave what will only hurt in a year.
A wrong brand color shows up in a screenshot, and someone complains the same day. Inconsistent spacing goes unnoticed for months and then becomes a redesign no one can find time for. So the values to tokenize early are the ones that don't hurt yet, like spacing.
In the design system, discipline doesn't come into it, because the choice isn't available: a padding prop will not accept a pixel value.
Two sources of truth that drift apart
B also shows what goes wrong when a system is adopted halfway.
B overrides 69 of the 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.
No one decided on that split. It is where the work stopped, and now half the surface tracks one source and half tracks the other, with nothing to say which is which.
What the compiler catches
I 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:
1<Box padding="16px" /> // string is not assignable to a space token2<Box gap={7.5} /> // 7.5 is not a step on the scale3<Box background="teal" /> // not a background token4<Box maxWidth="1200px" /> // not one of the seven widths5<Box marginTop={-3} /> // negative steps are strings: "-3"6<Text variant="huge" /> // not a text variant7<Text tone="brand" /> // not a tone8<Text align="left" /> // it's "start", not "left"9<Box style={{ padding: 16 }} /> // there is no style prop10<Box border="default" /> // border is a boolean hereThe same mistakes, written as styled-components:
1const A = styled.div`2 padding: 16px;3 gap: 15px;4 background: teal;5 max-width: 1200px;6 margin-top: -3px;7 font-size: 42px;8 color: #brand;9 text-align: left;10 paddin: 16px; /* misspelled property */11 colour: red; /* misspelled property */12`;Here none of the ten is caught. All of it compiles.
The last two, paddin and colour, are accepted and do nothing, without an error or a warning. The element is not 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. The production site doesn't have that rule, so there the count is zero.
For someone new, that is the difference between an editor that won't let them get it wrong and finding out when someone notices.
What a developer has to remember
| 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 and seven widths. Font size can't be set at all. You pick a text variant instead of a number.
In B you have to memorize these values. In C the editor offers them as you type.
Documentation the build keeps current
| 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 or no fixture, or if the generated props are out of date. The documentation can't fall behind the code, because the build stops first.
I'd argue this is worth more than the savings in code. Components without documentation get re-implemented instead of reused, which is how you end up with twenty-six of them.
A second page built mostly from imports
The first page built on anything pays for the setup. So I 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 the time to fix one mistake.
I won't put that number next to human effort. It is an agent's time, and the baseline isn't clean either: the production site was also built with an agent, over two and a half days and thirty-eight commits, and Git measures calendar time, which is a different thing from work.
What I can say 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 the column ratio and the form were new. Everything else was imported.
The page chrome was extracted into its own file at that point, when a second page needed it, and not before. Extracting it earlier would have meant guessing at what the pages would share.
What I'm not claiming
- The mobile figure in the table above only classifies the code. No port had been done at the time. The port has since been done and run on a device, and the post about porting this page describes it, along with the number it produced.
- I have no measurement of build time that compares with human work, for the reasons above.
- The contact form has no transport. It doesn't send anything anywhere, and both the code and the screen say so.
- The three versions are not functionally identical. The original has dropdown menus and a burger menu, and the rebuild leaves them out by design.
- The contact comparison needs a caveat. The production form has a captcha and a server action, which I didn't build in the new version. Even after generously removing sixty lines from the production side, it's 312 against 137.
Writing
If you’re working on something similar, send us the details.
The first call takes 30 minutes, and you’re under no obligation.