When your app has hundreds of components and a small team, having a shared set of rules isn't optional. This is how we did it.
Spindare has hundreds of screens. A feed, a challenge system, a leaderboard, user profiles, settings, onboarding flows, notifications, a reward wheel, a social graph. When we started building it, we didn't have a design system. We had a Figma file and good intentions.
By screen 40, the inconsistencies were already obvious. Buttons in different screens had slightly different padding. Border radii were inconsistent. Spacing was eyeballed. Dark mode had holes. This is how we fixed it, and what we'd do differently from the start.
On the web, inconsistency is tolerable to a point. Users scroll past it. On mobile, users tap, swipe, and gesture through interfaces that need to feel native and predictable. A button that's 2dp taller on one screen than another is the kind of thing users notice without being able to articulate what's wrong. They just say the app 'feels off'.
With hundreds of screens and a team of two, without a design system we were going to ship something that felt off.
We started with a design token file. Not components yet, just values. Every colour, every spacing unit, every font size, every border radius, every shadow defined in one place:
// tokens.ts
export const colors = {
primary: '#F97316',
primaryDark: '#C2410C',
background: '#0A0603',
backgroundElevated: '#141009',
surface: '#1C1510',
border: 'rgba(255,255,255,0.08)',
text: {
primary: 'rgba(255,255,255,0.92)',
secondary: 'rgba(255,255,255,0.52)',
muted: 'rgba(255,255,255,0.28)',
},
} as const;
export const spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
'2xl': 32,
'3xl': 48,
} as const;
export const radius = {
sm: 6,
md: 12,
lg: 16,
xl: 24,
full: 9999,
} as const;This single file became the source of truth. Every StyleSheet in the app imports from here. No hardcoded colour hex strings anywhere. No magic numbers.
With tokens established, we built primitive components that consumed them. Not full feature components, just the primitives that everything else is built from:
Every feature component is composed from these primitives. A PostCard is a Card containing a Box, Avatar, Text components, and a row of Icon buttons. Nothing in PostCard creates its own padding or colour. It delegates that to the primitives.
Spindare is dark-first. We don't support light mode yet. This made the token layer simpler, no semantic colour mapping needed. But we still had to make sure dark mode was systematic rather than 'dark background with white text'.
The key insight: dark UIs need more elevation levels than light ones. In light mode, surfaces are distinguished by hue (grey tints). In dark mode, you distinguish them by lightness, how far above the background a surface sits. We defined four elevation levels: background, surface (slightly raised), elevated (cards, modals), and overlay (bottom sheets, dialogs). Each level is a specific token, not a one-off colour choice.
We didn't document the system. We knew what the tokens meant, but new screens added later would occasionally use the wrong token because there was no written guidance on when to use `surface` vs `elevated`. We fixed this eventually with a short internal wiki page, but it should have been written when we created the tokens.
We also didn't build a component preview environment early enough. A Storybook equivalent for React Native (or even just a DevScreen with all the primitives rendered) would have caught visual regressions much earlier. We added one in month three. It caught four bugs immediately.
The ROI on a design system front-loads the cost. The first week of building the token and primitive layer feels slow. Every week after that, building new screens becomes faster than it would have been without it. If you're building an app with more than 20 screens, start with the token layer on day one.
Written by
Kristian Gjergji
Developer · Kosovo / Italy