Almost everyone's first attempt — mine included — is to build a component
package and use it in all three applications. It works while all three are web.
The moment a native app joins, it breaks: React Native has no <div>, no plain
className, and a <button> is not a button.
The uncomfortable conclusion arrives early: web and mobile do not share components. What they share is something else, and it turns out to be the part that mattered.
What actually gets shared#
Three levels, most valuable first:
1. The tokens#
Colours, type scale, spacing, radii, durations. Defined once, consumed by each platform in its own way: on web as CSS variables feeding a Tailwind preset; on mobile as a NativeWind preset plus a theme object.
/* tokens/colors.css — the only file in the repo with a literal colour */
:root {
--background: 215 37% 6%;
--surface: 213 37% 9%;
--primary: 200 81% 52%;
--foreground: 210 53% 94%;
}
The file is not the point: the point is that no component knows a colour.
They all use bg-surface, text-foreground, border-border. The day the brand
changes, one file changes and all three applications move at once.
2. The language#
This is worth more than it looks. If the web button takes variant="primary"
and the mobile one calls it type="filled", everyone switching platforms
relearns the system.
Same names everywhere: primary, secondary, ghost, link. Same sizes:
default, lg, icon. Same state tones: success, warning, danger.
The implementation diverges — it has to — but the conversation does not. Someone can say "the secondary button in the payment modal" and mean the same thing in all three applications.
3. The pure logic#
Currency and date formatters, validation, timezone helpers. None of it touches
the DOM, so it genuinely gets shared. With one condition: no window or
document at the entry point, or the native bundler blows up.
The type scale is the best example#
On web:
fontSize: {
h1: ['clamp(2rem, 3.5vw + 1rem, 3rem)', { lineHeight: '1.15' }],
h2: ['clamp(1.6rem, 1.6vw + 1rem, 2.1rem)', { lineHeight: '1.25' }],
body: ['1rem', { lineHeight: '1.7' }],
}
On mobile there is no clamp() and no fluid viewport, so they are fixed numbers:
// theme/typography.ts, in lockstep with the NativeWind preset
export const typography = {
h1: 28,
h2: 22,
h3: 18,
body: 16,
caption: 13,
};
The implementations look nothing alike. The names do. And that is the whole
advantage: text-h2 means "section heading" in all three applications, whether
underneath it is 34px fluid or 22px fixed.
With one rule I enforce without exceptions: no text-[17px], and no default
Tailwind classes (text-sm, text-lg). If a size is missing, it goes into the
scale. A loose size is a design decision made by accident at eleven at night.
Why I forbid cloning components#
The pattern that kills a design system is this one:
"The ui-kit button is almost what I need, but this one has a different border. I'll copy it and tweak it."
Now there are two buttons. In six months there are five, none identical, and changing the focus ring means touching five files and remembering all five.
The rule is simple and admits no convenient exceptions:
- Does it exist? Use it.
- Almost the same? Add a
variantto the original, in its package. - New and used in more than one place? It goes in the package.
- Unmistakably one-off? It can live next to its screen.
And zero overrides at the call site: no !border-0, no style={{}}, no
className="bg-[#123456]" to make something different. If the difference is
legitimate, it goes up as a prop. If it is not, it should not exist.
What NativeWind does not do, and how you find out late#
This is the part nobody mentions and that costs days.
With NativeWind, some utilities do not apply at runtime and fail silently: no error, no warning, the style simply is not there.
The ones that have bitten me:
| Utility | What happens |
|---|---|
Negative margin (-mt-*, -mx-*) | Not applied |
Axis borders (border-y, border-x) | Not applied |
Sometimes py-* and some heights | Inconsistent |
| Remote images without explicit height | Fall back to intrinsic size |
The fix is not elegant and it is the right one: for positioning, borders, critical padding, and remote image heights, use numeric inline styles.
// The overlay has to sit exactly there. `-mt-5` would not work.
<View style={{ position: 'absolute', top: -20 }}>
Documenting it matters more than fixing it. A silent failure written down nowhere gets made again every time someone new joins — and costs the same afternoon of debugging every time.
A design system does not break: it erodes#
Nobody decides to destroy it. What happens is that on a Tuesday, in a hurry,
someone drops a #2ecc71 because "it's just this one case". And it works, and it
passes review, and three months later there are fourteen.
That is why the rules holding the system up cannot be team agreements. They have to fail on their own:
// The linter rejects the three patterns that erode the system
'no-restricted-syntax': ['error',
{ selector: 'JSXAttribute[name.name="className"] > Literal[value=/bg-\\[#/]',
message: 'Hardcoded colour. Use the theme utilities.' },
{ selector: 'JSXAttribute[name.name="className"] > Literal[value=/text-\\[[0-9]/]',
message: 'Arbitrary size. Use the scale.' },
{ selector: 'JSXAttribute[name.name="style"]',
message: 'Inline style. Move the variant into the package.' },
];
With that, the stray colour never reaches code review: it breaks the build on the machine of whoever wrote it, which is the only moment when fixing it is cheap.
When I would not build a design system#
With a single application, you do not need one. A well-organised
components/ folder does the same job without the cost of maintaining,
versioning, and documenting a package.
It starts paying off when:
- There are two or more applications that must look the same.
- There are two or more people making visual decisions.
- The product will live long enough for the brand to change at least once.
If none of that holds, a UI package is a layer of indirection that only adds files to read. And if it does hold, every week you delay makes the migration more expensive.