Seven components and four exceptions
We rebuilt our marketing page in our own design system — not to redesign it, but to find out exactly where the system runs out.
We have a cross-platform design system covering Next.js, a Vite SPA and React Native. It has 82 registered components, a catalog of 81 pages, and a verification layer that fails the build when a convention is broken.
None of that tells you whether it can carry a page.
A component library tests itself component by component, which is a test of the parts and not of the whole. So we took the existing marketing page — one HTML file with inline styles and a small reactive script — and rebuilt it entirely from the system.
The goal was never a nicer page. The goal was a list: which components carry a real screen, which four things are missing, and what each missing thing actually costs.
Seven components carried the page
139 elements in the finished page come from the design system:
Box 63
Text 55
Link 9
Divider 7
Icon 2
Badge 2
Surface 1Seven components for an entire marketing page. Not the eighty-two in the registry — seven.
That was the first useful finding, and it inverts the assumption a library is usually built on. The catalog wasn’t too narrow. Breadth wasn’t the constraint at all.
Four exceptions, each with a name and a delete condition
Against those 139 elements there are exactly four escapes from the system, all collected in a single file rather than scattered:
- three
<div style>— flex-basis for grid cells, andposition: sticky - one
styled.a— the skip link
Each one has a written reason and a condition under which it disappears. That’s the difference between an exception and a leak: an exception you can count, explain, and eventually remove. A leak just spreads.
The ledger — eight things the system couldn’t do
This is the actual output of the exercise. Not the page — the list.
| 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 colour |
| 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 colour, 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 those were already sitting in our plan as future work — a hero font size and a link-styled button. Building the page turned them from an assumption into a measured finding with a cost attached.
That’s the argument for doing this at all. A backlog assembled in a meeting is a list of things someone imagined wanting. A backlog assembled by building a real screen is a list of things that actually stopped you, in order, with evidence.
How much of it goes to mobile
This is where we have to be careful, because there are two honest numbers and one dishonest one.
Across everything shipped — 1,402 lines — 76% could run on React Native. That number is inflated, and we’re not going to use it. 521 of those lines are a content file: pure text in two languages, no imports, no JSX. Of course it ports. Counting it is padding.
The honest cut is the UI code alone — 881 lines:
| lines | share | |
|---|---|---|
| Portable as-is | 306 | 35% |
| Portable after substitution | 241 | 27% |
| Web-only | 334 | 38% |
| Usable on mobile | 547 | 62% |
“After substitution” means the grid wrappers get replaced by a plain Box. On a phone the layout is a single column anyway, so that substitution is mostly deletion, not rewriting.
And the web-only third deserves a second look. 70 of those 334 lines are blocked by a single <a href> — outbound links and mail. That’s a swap for the platform’s link-opening call, not an architectural boundary.
The genuinely irreducible web-only code is about 155 lines, roughly 18%: the sticky navigation, the footer, the main landmark with its skip link, and the framework’s routing functions.
All of which is shell. None of it is content.
By section, out of ten blocks on the page: two are portable as-is, four after substitution, four are web-only — and all four of those are navigation, about, footer and the route wrapper.
Two things came out better than the original, for free
Neither was an objective.
It works in both themes. No colour is written anywhere in the page; everything resolves through semantic tokens. The original hardcodes its background and accent, so it exists only in dark.
Both languages are indexable. Language is a route segment rather than a stored preference, so each language prerenders as static HTML — about 152 KB per language. On the original, a crawler only ever saw English.
The whole page is a server component. No client islands, no state.
Three silences
Here’s the part we’d take to any team, regardless of whether they have a design system.
Every one of the three real problems we found had passed the compiler, the linter and the build.
An icon name that was wrong and broke nothing
We asked for a bullet point. The name we used turned out to be an alias for a list icon — three lines with dots, not one dot.
The icon registry is open by design, so names are typed as strings. Which means: the compiler is quiet, the linter is quiet, the build passes, and the render produces the wrong glyph — or an empty box of the correct size.
It was caught by looking at a screenshot. Nothing else in the chain had an opinion.
The fix, incidentally, was to stop using an icon: a badge with no children is an 8px dot, and it was also the only way to get the full-strength accent background the ledger says we’re missing.
The lesson is about the trade. An open registry buys extensibility and pays for it in silent misses. Our verification layer checks the 63 live catalog fixtures, not 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.
Render the web-only wrappers under the native test project and the test passes, printing this quite calmly:
{"type":"div","props":{"style":{"position":"sticky","top":0,"zIndex":100}}}
{"type":"a","props":{"href":"#x"}}The test renderer records a tree. It does not ask whether a host component exists. On an actual device, a div fails at the view config lookup.
And the import doesn’t save you either: the native styled-components proxy returns a function for styled.a as readily as for anything else, because it accepts any key.
Four levels of silence: import, compiler, linter, test.
The check that does work is small — enumerate the host components in the rendered output and fail if anything other than View or Text appears. That’s also how we proved the one genuinely portable card actually is portable: it emits only View and Text, and nothing else.
A decorative dot inside an accessible name
The brand dot at the end of the heading was being read as part of the accessible name, with a space in front of it: “…systems that can’t stop .”
Small. But it’s the same category as the other two: the accessible name we assumed and the accessible name we measured were different strings, and only one of them was real.
What a mobile port would actually take
Not a rewrite. A different shell.
| Web | Mobile |
|---|---|
| grid wrappers | gone — one column, a box with a gap |
| sticky bar | an app bar, or nothing |
| skip link | meaningless — no tab focus |
<main> / <footer> | a screen wrapper: safe area, scroll, content |
| framework routing | the navigation adapter that already exists |
<a href> for mail and outbound links | the platform link call |
The practical move 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 is already shaped that way — which is why it ported.
What we did not measure
We’d rather say this than have someone assume it.
The page has not been run on a device or a simulator. The portability claim rests on which host components the code emits, not on a launched application. “It emits only React Native host components” is true. “It’s been tested on a phone” is not, and we’re not going to write it.
No Lighthouse run, no client bundle measurement. The verification suite passes — 674 tests across four projects, exit code zero — and in a headless browser there were no console errors, no horizontal scroll at 390px, 29 links with correct accessible names, and a clean heading hierarchy. That’s what was checked. Performance was not.
The mobile port hasn’t been done. The percentages describe the code as it stands and the class of change each part would need. They are not a report from a port that happened.
What we’d tell another team
Build a real page with your own system before you trust it. Not a demo, not a kitchen sink — something someone would actually have to ship. Ours needed seven components and produced eight concrete gaps, two of which we’d only guessed at.
Count your exceptions and give each one a delete condition. Four exceptions in one file is a system with known edges. The same four scattered across the page is a system that’s quietly stopped being one.
And assume the expensive failures will be silent. All three of ours passed every gate we had. The wrong icon rendered. The impossible screen tested green. The accessible name read differently than it looked. None of them were errors — which is precisely why they survived.
This came out of building a cross-platform design system covering Next.js, React Native and a Vite SPA. Read the case study →