Case study 01 · Platforms and design systems
A design system that keeps web and mobile in sync
I built CDS after running into the same five problems on eleven projects. It has 74 components and 678 tests, and this site runs on it.
74 / 678
the headline number
1 constraint
The same components had to run on Next.js, Vite and React Native without the web and mobile versions drifting apart.
The story in 5 steps
Stack
- TypeScript
- Next.js 16
- React Native 0.87
- Vite
- styled-components
- pnpm workspaces
- Figma MCP
- custom ESLint plugin
01 · Context
Web and mobile versions of a product start out identical, and two years later they share nothing but a name.
Web and mobile versions of a product start out identical, and two years later they share nothing but a name. Figma is one source of truth and the code is another, and nobody knows which one is correct. Conventions live in a README and are followed until someone gets busy. Every new product builds its component layer from zero. And a product sold under other companies' brands either has design tokens or ends up as thirty copies that nobody can maintain.
In CDS, design tokens arrive from Figma automatically over an MCP server. A theme token resolves differently on each platform: on the web it becomes a CSS variable, and on native it becomes a sentinel value that the native resolver then replaces. That keeps the platform split one layer below the call site, so it never reaches the code that uses a component. The conventions are checked automatically: ~2,450 lines of verification make the build fail as soon as one of them is broken.
The system has 74 components and 678 tests, and its 81-page catalog is generated from the source code.
02 · Five problems
I ran into each of these problems on a real project before I started building CDS.
I ran into each of these problems on a real project before I started building CDS.
1. Web and mobile drift apart
On a meeting platform, five people spent eight months building, each in their own direction, and no shared component layer ever emerged. The codebase ended up with three UI frameworks and four dropdown libraries. On a marketplace, the dashboards and the mobile app did share a library, but the public site went its own way.
The usual tools make divergence free. Platform.select puts a branch wherever it is convenient, and conditional imports let any file diverge. Neither leaves a trace, and nothing fails when they multiply.
What CDS does: it moves the platform split one layer below the call site, so the code that uses a component never has to check the platform. createTheme() turns a token into a string that resolves per platform, so ${theme.color.fg.default} behaves the same everywhere. Shared style recipes sit above that layer and both platforms apply them; they differ only in the trigger, :hover on the web and pressed on native. There is one definition of each color, so colors cannot drift apart.
The login screen shows the result: its component call is byte-identical in all three apps.
b768aa14a346 apps/web/app/LoginScreen.tsx Next.js 16, RSC
b768aa14a346 apps/spa/src/routes/LoginRoute.tsx Vite SPA
b768aa14a346 apps/mobile/App.tsx React Native 0.87LoginForm.tsx is a single file of 135 lines with no native twin. It is assembled from Box, Button, Card, Input and Text, and the platform split happens inside those primitives.
The wrapper around it differs: on the web a Box centers the form, on the phone a SafeAreaView holds it, and the API URL is different. Those differences come from the platform and the product, and the design system adds none of its own.
2. Figma and the code are two sources of truth
I have run into this everywhere. On a white-label product with thirty brands, a plugin exported the tokens from Figma. That was automated, but it worked in one direction only, and the tokens easily fell behind the design. On most projects, the only link between Figma and the code is a person copying hex values.
Design changes on its own schedule and code on another, and without a mechanism between them the two are correct at the same moment only by accident.
What CDS does: design tokens arrive from Figma automatically over an MCP server, with a manual path as a fallback. The token layer is a package: core depends on tokens, and ui depends on core, so a change enters in one place and nobody has to search and replace it across the code.
3. Conventions written in a README stop being followed
On a marketplace with nineteen repositories, the same queue helper had been copied into seven of them, and nothing in the setup prevented it.
Nothing enforces a convention that is only written down, so it lasts until the first week nobody has time for a careful review.
What CDS does: every invariant has a check, and the build fails as soon as one is broken.
| Script | Lines | Checks |
|---|---|---|
verify-tokens | 902 | token integrity |
verify-conventions | 1,056 | orphan-native, twin-exports, use-client, shared-platform-import |
verify-katalog | 485 | 10 hard checks on the catalog |
That is ~2,450 lines of verification, plus an ESLint plugin with four rules.
The rule that holds the architecture together is that a pattern must not have a .native variant. It is checked by pattern-native in verify-conventions.ts, which fails CI across 11 subjects. That rule is why the login screen stayed shared, and it will keep the screen shared after I stop paying attention.
CI runs the same chain instead of reimplementing it: pnpm verify plus three real builds, for Next, Vite and Metro. The real builds are there because next build is the only place where the 'use client' rule is actually verified, and a check that runs only in a script could pass while the real build fails.
4. Every new product starts its component layer from zero
I hit this while building three products of my own. Without a shared library, each of them would grow its own buttons, forms and modal, and the three products would end up with three sets of bugs.
What CDS does: it is a package that my financial platform already uses, and this site is built on it too.
It is a full component set: primitives (Box, Text, Button, Input, Switch, Slider), forms (Form, Field, Select, RadioGroup, OTPInput, PasswordField), overlays (Modal, Popover, Menu, Drawer, Tooltip, Toast), navigation (Link, Tabs, AppBar, TabBar, Breadcrumbs) and data (Table, Pagination, Accordion).
I measured what sideEffects: false does: an entry that imports only Box went from 253,075 B to 241,309 B. The trap is documented next to that setting. @cds/tokens must not be marked false, because then reset.css is dropped and the layout collapses without a single error. Nothing in the toolchain reports that failure, so the note sits where the next person will look.
5. Naive multi-brand theming multiplies the variants
One construction product is sold under more than thirty brands. Built naively, with a separate version of every component for each brand, its hundred components would become three thousand variants to keep up to date.
What CDS does: components refer to colors only through token names. A brand is added by supplying a new set of token values, and no component changes. The approach was proven on that construction product, and in CDS it has been part of the architecture since the first commit.
03 · The principle
38 of 74 components have a native twin, which sounds as if half the system had diverged.
div to View or input to TextInput, and do nothing more.38 of 74 components have a native twin, which sounds as if half the system had diverged. In fact, the twins sit almost entirely in one layer:
| Layer | Twins |
|---|---|
primitives/ | 27 |
overlay/ | 8 |
form/ | 2 |
navigation/ | 1 |
patterns/ | 0 of 26 |
33 of the 38 twins import no other component. They are leaves that translate a single platform element, div to View or input to TextInput, and do nothing more. The split sits at the bottom of the tree, which is why nothing above it needs to know about it.
A twin is allowed for only three reasons, and two of them have nothing to do with elements.
A different element. This covers 33 of the 38 twins. The change is mechanical and contained, and the components above never see it.
A different event. Tooltip.native exists because there is no hover on a touch screen, so the trigger is a long press, deliberately without a delay. On the web, delayShow prevents flicker while the cursor crosses a row of icons. A long press already signals intent, so a delay would only make the tooltip feel broken.
A different window. NativeSelect.native exists because a system picker must sit above the keyboard, and the only way to a separate native window is an RN Modal. The shared Portal hands the tree to the host, which would leave the list underneath the keyboard. That failure shows only on a device, and only inside a form.
The remaining three twins import exactly one component each and do not change the picture.
No pattern has a twin at all, and that is enforced: pattern-native in the verification layer fails CI across 11 subjects as soon as one appears. Modal shows that a hard case can stay shared, with its portal, focus management and measurement, and LoginForm shows that a whole screen can.
When a pattern needed a split, I left the pattern shared, extracted the primitive whose only difference was the element and gave that primitive the twin. It happened four times under pressure, which is why I treat it as a principle. The extracted primitives include PositionedLayer, DialogActionsRow and MenuSeparator.
04 · Numbers
| Components | 74 |
| With native twins | 38, roughly half of them literally the same file |
| Test cases | 678 across 62 suites |
| Catalog pages | 81, props tables generated from source |
| Verification tooling | ~2,450 lines plus 4 ESLint rules |
| Bundle, Box-only entry | 253,075 B → 241,309 B |
| Duration | 6 months, ongoing |
| In use by | this site, and my financial platform |
05 · The review
I reviewed all 75 components the system had at the time and found 188 issues.
accessibilityLabel on a node with no role is announced by neither platform.Omit<BoxProps, …> leaves id, a11yRole, accessibilityLabel and pointerEvents in the contract, but a branch that does not destructure them passes them into ...box, and from there into a style helper that returns only $ props.I reviewed all 75 components the system had at the time and found 188 issues. They came down to three root causes, and the toolchain catches none of them: the typecheck, ESLint and the build all pass.
A label on an element with no role. accessibilityLabel on a node with no role is announced by neither platform. On the web, aria-label on a bare div does nothing, because the ARIA specification does not let a generic role take an author-supplied name. On native, accessibilityLabel without accessible is never read at all. Nine components had this bug, and the correct pattern already existed in the same package, where Icon always pairs its label with role="img".
An inherited prop that never reaches the element. Omit<BoxProps, …> leaves id, a11yRole, accessibilityLabel and pointerEvents in the contract, but a branch that does not destructure them passes them into ...box, and from there into a style helper that returns only $ props. Nothing complains, because an omitted prop is simply undefined, which is valid. Four sibling components wired three different subsets of these props: one wired all four, two wired two each, and one wired none.
A comment describing an earlier version of the system. A note describes a limitation, the limitation is later removed, and the note stays. This is the most insidious class, because a reader trusts the comment and never tries what has been possible for months. A worse variant is a JSDoc block shifted by one prop, so one prop carries two descriptions and its neighbor carries none. That text is generated into the catalog, where it becomes the public documentation.
Knowing why the three tools stayed silent matters more than the count of 188, because it decides whether the next bug of the same kind gets caught. Two of the three causes are now covered by verification rules, so catching them no longer depends on a review.
If I did it again, I would run that review when the system had 30 components, long before it reached 75. The same three causes were already present then, and correcting them would have cost an order of magnitude less. All 188 issues are fixed; coverage for aria-label and tabindex is still open.
Related services
The services that draw on this work.
- MobileReact Native developmentOur own design system runs on three platforms. When our marketing page was ported to a phone, 91% of the code the phone runs came from the web unchanged.
- Web developmentReact SPA architectureOur own design system has three targets: a Vite SPA, Next.js and React Native. The call that renders the login screen is the same, byte for byte, in all of them.
- Web developmentSaaS MVPOur design system, with 74 components and 678 tests. This site runs on it, and so does a financial platform of our own.
- MobileWeb to mobile portOur design system’s login screen calls its components with byte-identical code in all three of its apps: Next.js, a Vite SPA and React Native. Only the wrapper around it differs per platform.
- Cloud and DevOpsCI/CD and code standardsOur cross-platform design system has about 2,450 lines of verification that fail the build. CI also runs a real build with each of Next, Vite and Metro, because one of the rules is only truly checked by next build.
- Cloud and DevOpsAutomated and end-to-end testing678 tests across 62 suites in our cross-platform design system, covering the web, a SPA and native.
- AIAI in your engineering teamIn our own design system, a generated change that breaks one of its conventions fails the build, because about 2,450 lines of verification code check them.
- Design systemsDesign system and component libraryOur own cross-platform design system: 74 components, 678 tests and an 81-page catalog generated from the source code. Its login form is a single file that a Next.js app, a Vite app and a React Native app all use unchanged.
- Design systemsDesign tokens and multi-brand themingIn our cross-platform design system, a theme token becomes a CSS variable on the web and a value the native resolver fills in on the phone. Each color is defined once for every platform.
- Design systemsFigma to codeOur current design system takes palettes, scales and theme values from Figma automatically, over an MCP server.
Related writing
Posts that came out of this project.
- 26 SEP 2026 · 5 minOur design system as a skill made an AI agent at least 2× fasterAn AI agent built the same design in 31.7 hours with our design system as a library, then 2.5 hours with it as a skill. A newer model explains part of the gap.
- 9 SEP 2026 · 10 minTesting a design system on a real page: 7 components and 8 gapsThe 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.
- 5 SEP 2026 · 6 minA half-built design system made our page bigger than plain HTMLI 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.
- 5 AUG 2026 · 7 minWhere I split web and React Native code in our design systemOf our 74 components, 38 have a React Native twin, 27 of them primitives and none a pattern. Each twin exists for a different element, event or window.
- 8 JUL 2026 · 4 minThree kinds of design system bug that TypeScript and ESLint missReviewing 75 components turned up 188 issues from three causes: labels on nodes with no role, props accepted and then dropped, and outdated comments.
Case study 01
Working on a similar problem? We reply within one business day.
The first call takes 30 minutes, and you’re under no obligation.