building your own component library
A React Native design system bundles design tokens, proven components and theming into one versioned package, so teams stop rebuilding buttons, cards and forms from scratch for every new app. This article shows how a real component library grows from the first tokens into a reusable npm package.
Table of Contents
- 1. Why build your own design system in React Native?
- 2. Design tokens: the single source of truth
- 3. Primitive components: Button, Text, Card, Input
- 4. Theming with Context and a ThemeProvider
- 5. Styling strategy: NativeWind versus StyleSheet
- 6. Storybook for React Native: isolated development
- 7. Monorepo and packaging as an npm package
- 8. Versioning, changelog and breaking changes
- 9. Design system in direct comparison
- 10. Summary
- 11. FAQ
1. Why build your own design system in React Native?
A design system is more than a collection of nice looking components. It is the binding contract between product design and engineering: colors, spacing, typography and interaction patterns are defined once and then reused consistently. Without that foundation, apps drift apart within a few sprints because every team invents its own button variants, spacing values and font sizes. A central component library stops this fragmentation before it starts.
The effort of building your own React Native design system pays off especially once more than one app or more than one team needs the same UI building blocks. A company with a customer app, an internal admin tool and a partner portal benefits massively from maintaining Button, Input and Card only once. Bug fixes, accessibility improvements and new color themes then land centrally in the library and propagate to every consumer app through a simple version bump.
It is important to distinguish this from a pure component collection: a real design system also documents decisions, for example why a particular spacing value is the default or when which button variant applies. This documentation ideally lives right next to the code, for example in Storybook, so design and engineering share the same source of truth and questions in chat drop to a minimum.
2. Design tokens: the single source of truth
Design tokens are the atomic values of a design system: colors, spacing steps, font sizes, radii and shadow depths as named constants instead of scattered hex codes throughout the code. The decisive advantage of a component library built on tokens is that a color change happens in exactly one place and automatically propagates through the entire app, instead of being manually searched and replaced across fifty files.
In practice, tokens are defined either as a plain TypeScript object or as a platform-neutral tokens.json, from which a build step generates both the React Native constants and, if present, design tool exports (the Figma Tokens plugin). This decoupling ensures designers and developers reference the same values, without anyone manually syncing between Figma and code.
// tokens.ts — single source of truth for the design system
export const colors = {
primary: '#4338ca',
primaryDark: '#312e81',
surface: '#ffffff',
surfaceMuted: '#f1f5f9',
textPrimary: '#0f172a',
textMuted: '#64748b',
danger: '#dc2626',
} as const;
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
} as const;
export const radii = {
sm: 6,
md: 12,
lg: 20,
full: 999,
} as const;
export const typography = {
bodySize: 16,
headingSize: 22,
lineHeightBody: 22,
fontFamilyBase: 'Inter-Regular',
fontFamilyBold: 'Inter-Bold',
} as const;
3. Primitive components: Button, Text, Card, Input
The first layer built on top of the tokens is the set of primitive components. Button, Text, Card and Input are the building blocks every screen is later composed from. Every primitive component in the component library should be controlled through variant props, for example variant="primary" | "secondary" | "danger" on the button, instead of allowing arbitrary style overrides from the outside. That prevents every consumer app from interpreting the button slightly differently.
A second key principle: primitive components encapsulate behavior, not just appearance. A Button should come with a loading state, a disabled state and correct accessibilityRole and accessibilityState props out of the box, so nobody has to reinvent this logic in every app and forget accessibility details along the way. This is exactly where the value of a real design system shows compared to a loose collection of UI snippets.
Composition beats inheritance: instead of building one giant Card component with twenty optional props, you combine smaller building blocks like CardHeader, CardBody and CardFooter. That keeps every single component manageable, testable and easy to document, and new layout variants emerge through rearrangement instead of new props.
// Button.tsx — primitive component with variant-driven styling
import { Pressable, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { colors, spacing, radii } from '../tokens';
type ButtonVariant = 'primary' | 'secondary' | 'danger';
interface ButtonProps {
label: string;
onPress: () => void;
variant?: ButtonVariant;
loading?: boolean;
disabled?: boolean;
}
export function Button({ label, onPress, variant = 'primary', loading, disabled }: ButtonProps) {
const isDisabled = disabled || loading;
return (
<Pressable
onPress={onPress}
disabled={isDisabled}
accessibilityRole="button"
accessibilityState={{ disabled: isDisabled, busy: loading }}
style={({ pressed }) => [
styles.base,
styles[variant],
isDisabled && styles.disabled,
pressed && styles.pressed,
]}
>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.label}>{label}</Text>}
</Pressable>
);
}
const styles = StyleSheet.create({
base: { paddingVertical: spacing.sm, paddingHorizontal: spacing.lg, borderRadius: radii.md },
primary: { backgroundColor: colors.primary },
secondary: { backgroundColor: colors.surfaceMuted },
danger: { backgroundColor: colors.danger },
disabled: { opacity: 0.5 },
pressed: { opacity: 0.85 },
label: { color: '#fff', fontWeight: '600', textAlign: 'center' },
});
4. Theming with Context and a ThemeProvider
As soon as multiple brands, a dark mode or white-label customers enter the picture, static tokens are no longer enough. This is where a ThemeProvider built on React Context comes in, swapping the active tokens at runtime without every component needing to change its imports. Primitive components then read their colors and spacing through a useTheme() hook instead of direct token imports, which makes the entire React Native design system theme-capable without touching the component code itself.
A common mistake is retrofitting theming into an already grown library. It is far more robust to define the ThemeProvider as a mandatory root of the component library from the start, even if only a single theme exists initially. That way, the later move to dark mode or multi-brand support remains an additive change instead of an invasive refactor across every component.
// ThemeProvider.tsx — runtime-swappable design tokens via Context
import { createContext, useContext, useState, type ReactNode } from 'react';
import { colors as lightColors } from '../tokens';
const darkColors = {
...lightColors,
surface: '#0f172a',
textPrimary: '#f1f5f9',
};
type Theme = { colors: typeof lightColors; mode: 'light' | 'dark' };
const ThemeContext = createContext<{ theme: Theme; toggleMode: () => void } | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<'light' | 'dark'>('light');
const theme: Theme = { colors: mode === 'dark' ? darkColors : lightColors, mode };
return (
<ThemeContext.Provider value={{ theme, toggleMode: () => setMode(m => m === 'light' ? 'dark' : 'light') }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
}
5. Styling strategy: NativeWind versus StyleSheet
When it comes to styling strategy, teams usually face a choice between classic StyleSheet.create and a utility-first approach like NativeWind, which brings Tailwind classes to React Native. For a design system, NativeWind has a lot going for it: tokens can be stored directly in tailwind.config.js as colors, a spacing scale and radii, so class names like bg-primary or p-md automatically match the central token definition instead of scattering magic numbers through the code.
Classic StyleSheet remains relevant nonetheless, especially for complex, dynamic styles that depend on animations or layout measurements, where utility classes reach their limits. Many production component libraries therefore combine both approaches: NativeWind for the bulk of static layout, StyleSheet for performance-critical or dynamically computed styles. What matters is that both paths reference the same tokens, so visual consistency does not depend on which styling mechanism was chosen.
6. Storybook for React Native: isolated development
Storybook lets you develop and test every component of the library in isolation from a real app. Instead of building a test screen in the middle of the main app to check a new button, you start a story that shows exactly that button with every relevant prop combination. This noticeably speeds up development and makes new variants immediately visible and discussable for the whole team, including design.
For React Native there are two practical paths: Storybook directly in the simulator via @storybook/react-native, or Storybook in the browser via React Native Web, which is especially handy for fast reviews in the pull request process since no emulator needs to be started. Many teams use both in parallel, because the web variant is easier to automate for CI snapshot comparisons, while the native variant tests real touch interactions and platform quirks.
# Initialize Storybook for a React Native design system package
npx storybook@latest init --type react_native
# Run Storybook in the iOS simulator
yarn ios --scheme UILibraryStorybook
# Run the web variant for fast PR reviews without a simulator
yarn storybook:web
7. Monorepo and packaging as an npm package
For multiple apps to consume the same component library, it must exist as a standalone package, not a copied folder. A Yarn or npm workspaces monorepo is the most pragmatic starting point: the library lives under packages/ui, the consumer apps under apps/customer and apps/admin, and all of them reference the library through a workspace link, without needing a real npm publish cycle for every local change.
Once the library needs to be used outside your own monorepo too, for example by a partner team or a separate codebase, a private npm package through a private registry like GitHub Packages or Verdaccio becomes the next logical step. What matters at this stage is a clean package.json with correct peerDependencies for react and react-native, so the library does not accidentally bundle its own incompatible React version.
{
"name": "@company/ui",
"version": "2.3.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"react": ">=18.2.0",
"react-native": ">=0.74.0"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"files": ["dist"]
}
8. Versioning, changelog and breaking changes
A design system without version discipline quickly becomes a source of frustration: one team changes the behavior of the Button, another app breaks unnoticed on its next update. Semantic versioning is mandatory here, not optional. Patch versions for bug fixes, minor versions for new, backward-compatible props and variants, major versions for breaking changes such as a changed prop signature or a removed color token.
Tools like Changesets automate this process: every pull request change to the component library gets a small changeset file, from which the version number and a readable changelog are generated automatically at release time. This decouples the decision of "how severe is this change" from the actual release timing, and prevents a maintainer from having to mentally track everything that accumulated since the last tag.
9. Design system in direct comparison
Whether building your own React Native design system pays off shows most clearly in a direct comparison with the status quo of many teams: components rewritten app by app or copied and pasted around.
| Dimension | Ad-hoc copy-paste | Versioned design system |
|---|---|---|
| Bug fix distribution | Manually applied in every app | One version bump for all consumers |
| Visual consistency | Drifts apart with every app | Central tokens enforce consistency |
| Accessibility | Implemented differently per app | Implemented once correctly, inherited everywhere |
| New developer onboarding | Has to learn each app-specific solution again | Storybook documentation as central reference |
| Theming / dark mode | Costly rebuild per app | Additive extension via ThemeProvider |
The table makes clear that the initial extra effort for tokens, theming and packaging only pays off once several apps or teams genuinely benefit from the component library. For a single small project, a fully built-out design system can be overkill, but for any organization with two or more React Native apps, it is almost always the cheaper option in the medium term.
Mironsoft
React Native development, design systems and app architecture
A design system of your own for your React Native apps?
We build and maintain React Native component libraries with design tokens, theming, Storybook documentation and clean monorepo packaging, so your teams ship consistent apps faster.
Token architecture
Colors, spacing and typography as a versioned single source of truth
Component build-out
Primitive components with theming, Storybook and accessibility
Monorepo setup
Workspaces, packaging and a versioning workflow with Changesets
10. Summary
A React Native design system of your own does not start with components, it starts with design tokens as the single source of truth for colors, spacing and typography. Built on top of that come primitive components with clear variant props, a ThemeProvider for runtime theming, and a deliberate styling strategy between NativeWind and StyleSheet. Storybook makes every component developable and documentable in isolation, and a monorepo with workspaces allows shared use across multiple apps.
The decisive difference from a loose component collection is discipline around versioning and changelog. A component library that consistently applies semantic versioning becomes a reliable foundation for multiple apps, instead of a source of unexpected breaking changes. The effort pays off as soon as more than one team or more than one app benefits from the same UI foundation.
React Native Design System — The Essentials at a Glance
Design tokens
Colors, spacing and typography as named constants instead of scattered magic numbers in the code.
Theming
A Context-based ThemeProvider makes dark mode and multi-brand support additive instead of invasive.
Storybook
Isolated development and living documentation for design and engineering together.
Monorepo & versioning
Workspaces for shared use, semantic versioning and Changesets against unexpected breaking changes.