Testing a design system on a real page: 7 components and 8 gaps
The page used seven of our 74 components, with four exceptions in one file. On a phone, 91% of the code it ran was unchanged, and four bugs passed every check.
Our cross-platform design system covers Next.js, a Vite SPA and React Native. It has 74 components, a catalog, and a verification layer that fails the build when a convention is broken. None of that tells you whether it can carry a real page. A component library is tested one component at a time, so its tests cover the parts and never the page as a whole.
Our existing marketing page was a single HTML file with inline styles and a small reactive script. I rebuilt it entirely from the system. The goal was a list rather than a nicer page: which components carry a real screen, which four elements have to escape the system, and what each missing feature costs.
Seven components carried the page
In the finished page, 139 elements come from the design system:
1Box 632Text 553Link 94Divider 75Icon 26Badge 27Surface 1An entire marketing page used seven of the system's 74 components.
That was the first useful finding, and it goes against the assumption a library is usually built on. The catalog was wide enough for the page, and its breadth was never the limit.
Four exceptions in one file, each with a reason and a delete condition
Against those 139 elements, the page steps outside the system in four places, and all four are in a single file:
- three
<div style>elements, for the flex-basis of grid cells and forposition: sticky - one
styled.a, for the skip link
Each one has a written reason and a condition under which it can be deleted. That is the difference between an exception and a leak: an exception can be counted, explained and eventually removed, while a leak spreads through the code.
Eight things the system couldn't do
This table is the list the exercise was meant to produce.
| Wanted | Why not | What the page got instead |
|---|---|---|
| 48px heading | display is 36px; the 5xl step exists in the scale but no variant consumes it | smaller heading |
| A link that looks like a button | Button has no to; the accent background token isn't full-strength; the base recipe fixes the radius | white pill instead of the accent color |
| Italic | font-style doesn't exist anywhere in the packages | constraint quotes without italics |
| Responsive grid | Box has no basis or min-width, and min-width: 0 is hardcoded | a <div style> per cell |
position: sticky | Yoga has no sticky, so the position type doesn't include it | a <div style> around the bar |
| Skip link | the visually-hidden component takes a text prop, with no children and no focus state | one styled.a |
| Dashed or single-sided border | the border prop is a boolean, one color, all four sides | a tinted pill instead of a dashed outline |
| Landmark roles | the role type has no main or contentinfo, because React Native doesn't | raw <main> and <footer> |
Two of these were already planned as future work: a hero font size and a link that looks like a button. Building the page turned both from assumptions into measured findings, each with a cost attached.
That is the reason to do this at all. A backlog drawn up in a meeting lists what someone imagined wanting. Building a real screen gives you a list of what stopped you, in order, with evidence for each item.
On the phone, 91% of the code it runs was reused unchanged
The first mobile figure I published, in the post that compares three versions of this page, was only a classification of the code, made before anything had run on a device. Since then the page has been ported and run on a phone in Expo Go, and the numbers from the port are better than that estimate.
| lines | files | |
|---|---|---|
| Shared: one file draws both web and phone | 975 | 4 |
| Split: web branch plus a native twin | 162 | 2 |
| Web only: chrome, framework routing | 582 | 4 |
| Native only: the shell | 47 | 1 |
On the phone, 1,077 lines execute, and 102 of them were written for mobile: 55 lines of native twin and 47 lines of shell. So 91% of what the phone draws was reused without a single change.
How the 91% compares with the earlier 62%
The earlier estimate counted 62% of the code as usable on mobile. That was a classification of the entire site, chrome included. The 91% is a share of what the phone draws. The two figures have different denominators, and both are correct.
A third figure matters more than either: the "needs rework per page" category dropped to zero. That rework was done once, in 55 lines.
The gap was one layout file
In practice, the 38% that looked web-only came down to four <div> elements in a single layout file.
The native twin replaces them. The grid becomes a box, two wrappers become passthroughs, and the skip link returns null. The bundler picks the branch by filename suffix, so the call site stays byte-identical. None of the page's sections was modified or rewritten.
This is what the 62% figure couldn't show. The portable part was never in doubt. What you can't know in advance is whether the non-portable part is spread across the codebase or concentrated in one place. Here it was one file. That followed from where the split was drawn and had nothing to do with luck.
What the phone is running
| Modules in the bundle | 1,015 |
| Bytecode | 2.2 MB |
| Bundle time | 3.8 s |
| Host components in the tree | View, Text, RCTScrollView; no web components |
| Design system components used | 8 |
| Hardcoded design values | 0 |
Two unplanned improvements: both themes and both languages
Neither was a goal of the rebuild, and neither took extra work.
The page works in both themes. No color is written anywhere in it, and everything resolves through semantic tokens. The original hardcodes its background and accent, so it exists only in dark.
Both languages can be indexed. The language is a route segment instead of a stored preference, so each language prerenders as static HTML of about 152 KB. On the original, a crawler only ever saw English.
The page renders entirely as a server component, with no client islands and no state.
Four bugs that passed every check
The four real problems I found would matter to any team, with or without a design system. Each of them had passed the compiler, the linter and the build. I caught three by looking, and a phone caught the fourth.
A wrong icon name that broke nothing
I asked for a bullet point. The name I used turned out to be an alias for a list icon, which draws three lines with dots instead of a single dot.
The icon registry is open by design, so icon names are typed as strings. The compiler and the linter have no objection to a wrong name, the build passes, and the render shows the wrong glyph or an empty box of the correct size.
I caught it by looking at a screenshot. Nothing else in the chain reported it.
The fix was to stop using an icon. A badge with no children renders as an 8px dot, and it was also the only way to get the full-strength accent background, which the table of gaps above lists as missing.
That is the price of an open registry. It makes the icon set easy to extend, and it pays for that with wrong names that nothing reports. The verification layer checks catalog fixtures and doesn't look at application code, so it could never have caught this.
A native test that stays green for a screen that cannot open
This one is worse. If you render the web-only wrappers under the native test project, the test passes and calmly prints this:
1{"type":"div","props":{"style":{"position":"sticky","top":0,"zIndex":100}}}2{"type":"a","props":{"href":"#x"}}The test renderer records a tree without asking whether each host component exists. On a real device, a div fails at the view config lookup.
The import doesn't catch it either. The native styled-components proxy accepts any key, so it returns a function for styled.a as readily as for anything else.
That makes four layers that let it through without a word: the import, the compiler, the linter and the test.
The check that does work is small: list the host components in the rendered output and fail if anything other than View or Text appears. The same check is how I proved that the one portable card really is portable, since it emits only View and Text.
A decorative dot inside an accessible name
The brand dot at the end of the heading was read as part of the accessible name, with a space in front of it: "…systems that can't stop ."
It's a small bug, but it belongs with the other two. The accessible name I assumed and the one I measured were different strings, and only the measured one was real.
A bug only the hardware could find
The first build crashed on the phone:
1ScrollView child layout (["alignItems","justifyContent"])2must be applied through the contentContainerStyle prop.The page itself was fine. The cause was in a primitive, where two of the system's own rules collided.
The native scroll primitive applies the shared stack recipe, and that recipe always emits alignment properties. That is by design: web and native have different defaults, so unless the value is written explicitly, the two platforms drift apart. That rule is correct.
React Native's rule is also correct: the children of a scroll container can't be aligned on the scroll view itself, because that alignment belongs on the content container. With both rules in place, the screen crashed.
The fix was to split the recipe into one version without the alignment properties and one with them. The scroll root takes the first, and the alignment moves to an inner view. That is where it belonged anyway, since it aligns the content and not the box that scrolls. The web side was left untouched.
Two things kept the checks from catching it. The runtime check that produced that message sits behind a development flag and also behind a condition on the style prop. And the test setup mocks the scroll view, so the real class never executes in a test. Typecheck, native tests and the production export were all green.
I added two tests that inspect the emitted style instead of waiting for an exception. I verified them the only way I think is worth doing: I reintroduced the bug, watched both tests fail on the exact property, and reverted it.
Why every check missed them
Three findings like these would still be easy to dismiss, but four are not.
Each of them passed every check I had, and always for the same reason: each check answers one specific question, and none of them asks whether the code means what its author thought it meant. No check in the chain asks whether an attribute does anything, whether a prop reaches an element, whether a comment is still true, or whether a component will exist on the device it's compiled for.
The fourth bug shows this most clearly: a phone found in three seconds what four automated layers had approved.
An error stops you on the spot and costs you a build. A bug that passes every check ships, and you pay more for it later, at a price you can't know in advance.
Porting took a different shell of 102 lines
The port needed no rewrite, only a different shell, 102 lines in all:
| Web | Mobile |
|---|---|
| grid wrappers | a box with a gap |
| sticky bar | passthrough |
| skip link | returns null |
<main> / <footer> | a screen wrapper: safe area, scroll, content |
| framework routing | the navigation adapter that already existed |
<a href> for mail and phone | the platform link call |
The practical approach is to extract each section into a file that takes only design system props, and let each platform bring its own shell. The one card that ported without a single change was already built that way, which is why it ported.
What I did not measure
I didn't run Lighthouse or measure performance in any other way. The verification suite passes: 678 tests across 62 suites, exit code zero. In a headless browser the page showed no console errors, no horizontal scroll at 390px, 29 links with correct accessible names and a clean heading hierarchy. The web output was unchanged after the port. Those are the things I checked, and speed was not one of them.
There is no screenshot from the device either. The screen was confirmed running in Expo Go, but this post has no image from it.
I also didn't compare build time against human work. A second page took under five minutes of agent time, but there is no clean baseline to compare it with, because the production site was also built with an agent. And git measures calendar time, not effort.
What I'd tell another team
Build a real page with your own system before you trust it. I don't mean a demo or a kitchen sink, but something someone would have to ship. Ours needed seven components and turned up eight concrete gaps, two of which I had only guessed at before.
Count your exceptions and give each one a delete condition. Four exceptions in one file mean the system has known edges. If the same four were scattered across the page, the system would have stopped working as one without anyone noticing.
Assume the expensive failures will be the ones nothing reports. All four of mine passed every check I had: the wrong icon rendered, the screen that couldn't open tested green, the accessible name read differently than it looked, and the compiler, the tests and the production export all approved the build that crashed on the phone. None of them raised an error, which is how they got through.
Writing
If you’re working on something similar, send us the details.
The first call takes 30 minutes, and you’re under no obligation.