Every React Native codebase we inherit has a styling story, and it is usually one of three: a thousand StyleSheet.create blocks with copy-pasted hex values, a half-migrated Tailwind setup, or a ThemeProvider from a styling library that was abandoned two major versions ago. None of them are wrong on day one. All of them hurt by the time you have forty screens, a dark mode requirement, a tablet layout and a designer who wants to change the brand spacing scale.
Styling is also where the New Architecture quietly changed the trade-offs. Runtime style objects that re-render trees, context-based theming that invalidates half the app on a colour-scheme flip, and useWindowDimensions hooks that fire on every rotation are all more expensive than the alternatives now available. Here is how we pick a styling layer in 2026, and what a design system built on it actually looks like.
The three real options
1. Plain StyleSheet plus a token module. Nothing to install, nothing to break on upgrade, zero runtime. You lose variants, media queries and automatic theming — you build those yourself.
2. NativeWind (v4). Tailwind class names compiled to RN styles at build time. Fantastic if your team already thinks in Tailwind or you share a design language with a web app. You get variants, dark mode, breakpoints and arbitrary values for free, and the className diff in a PR reads like the design change it represents.
3. Unistyles (v3). A C++/Nitro-backed styling layer built for the New Architecture. Its selling point is that theme and breakpoint changes update styles in native without re-rendering your React tree, which matters on screens with deep lists. You write StyleSheet.create as usual, but with a theme argument and variant support.
There is no universal winner. Our rough rule:
- Shared web + mobile product, Tailwind-fluent team → NativeWind.
- Performance-sensitive app, heavy theming, New Architecture only → Unistyles.
- Small app, or a library you publish →
StyleSheet+ tokens, so you add no peer dependency.
Whatever you pick, the styling library is not the design system. The tokens are.
Start with tokens, not components
Tokens are the contract between design and code. Keep them in one plain TypeScript module with no React and no styling-library import, so tests, native modules and any future migration can all read them.
// theme/tokens.ts
export const space = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48 } as const;
export const radius = { sm: 6, md: 12, lg: 20, pill: 999 } as const;
export const palette = {
brand500: '#1F6FEB',
brand600: '#1A5FCC',
neutral0: '#FFFFFF',
neutral900: '#0B0D10',
danger500: '#D93025',
} as const;
export const typography = {
body: { fontSize: 16, lineHeight: 24, fontFamily: 'Inter-Regular' },
title: { fontSize: 22, lineHeight: 28, fontFamily: 'Inter-SemiBold' },
} as const;
Then define semantic colours per theme. Components should never reference palette.brand500 directly; they reference colors.actionPrimary. That single indirection is what makes dark mode and white-labelling a config change instead of a refactor.
export const lightTheme = {
colors: {
background: palette.neutral0,
textPrimary: palette.neutral900,
actionPrimary: palette.brand500,
actionPrimaryPressed: palette.brand600,
textOnAction: palette.neutral0,
destructive: palette.danger500,
},
space, radius, typography,
} as const;
export const darkTheme = {
...lightTheme,
colors: {
...lightTheme.colors,
background: palette.neutral900,
textPrimary: palette.neutral0,
},
} as const;
export type AppTheme = typeof lightTheme;
If your designers work in Figma variables, export them to Design Tokens Community Group JSON and generate this file in CI. A generated token module that fails the build when a token disappears is worth more than any component library.
Variants beat style props
The single biggest source of styling mess is components that accept twelve optional style props. <Button color="#f00" padding={12} rounded bold /> gives every caller the ability to invent a new button. Variants close that door.
// Button.tsx — plain StyleSheet + tokens version
type Variant = 'primary' | 'secondary' | 'destructive';
type Size = 'sm' | 'md';
export function Button({ variant = 'primary', size = 'md', label, onPress }: Props) {
const theme = useTheme();
const s = styles(theme);
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
s.base,
s[size],
s[variant],
pressed && s[`${variant}Pressed`],
]}
accessibilityRole="button"
>
<Text style={[s.label, s[`${variant}Label`]]}>{label}</Text>
</Pressable>
);
}
With Unistyles the same idea is declarative, and the variant resolution happens outside React:
const stylesheet = StyleSheet.create(theme => ({
button: {
borderRadius: theme.radius.md,
alignItems: 'center',
justifyContent: 'center',
variants: {
variant: {
primary: { backgroundColor: theme.colors.actionPrimary },
secondary: { backgroundColor: 'transparent', borderWidth: 1 },
destructive: { backgroundColor: theme.colors.destructive },
},
size: {
sm: { paddingVertical: theme.space.xs, paddingHorizontal: theme.space.sm },
md: { paddingVertical: theme.space.sm, paddingHorizontal: theme.space.md },
},
},
},
}));
With NativeWind, variants live in tailwind.config.js plus a cva-style helper, and your tokens become the Tailwind theme — same discipline, different syntax. The rule that matters is: a caller picks a variant, never a colour.
Dark mode without a re-render storm
The naive implementation is a React context holding the theme object. Every colour-scheme change then re-renders every consumer, and on a screen with a long list that is a visible hitch.
Three things help:
- Do not put the theme object in the same context as anything that changes often. Split theme, auth and navigation state into separate providers.
- Memoise the theme object. A new object identity each render defeats every
React.memobelow it. If you are on the React Compiler, verify this in the compiled output rather than assuming. - Prefer a styling layer that updates styles natively. This is Unistyles' core pitch, and NativeWind's compiled
dark:classes avoid the context path too.
Also: respect the system setting by default, but store an explicit user override (system | light | dark). Read it before first paint — an async read of AsyncStorage after mount produces a white flash on every cold start. react-native-mmkv or expo-sqlite's synchronous key-value API is the usual fix.
Do not forget the shell around your JS: the native splash screen, the status bar style, the Android navigation bar and any native view backgrounds all need the same treatment, or your dark mode shows a white frame for 200ms.
Responsive layout: phones, tablets, foldables, and the keyboard
Dimensions.get('window') at module scope is a bug. It is captured once, and it is wrong after a rotation, on a foldable, in Android split-screen, and in an iPad Slide Over.
Use useWindowDimensions(), or better, breakpoints from your styling layer so the resolution does not go through React at all. Then design around a handful of named breakpoints rather than device checks:
const breakpoints = { phone: 0, tablet: 768, desktop: 1280 } as const;
Some specifics worth budgeting for:
- Safe areas, not constants.
react-native-safe-area-contextinsets, applied per-edge. With Android edge-to-edge now mandatory, hard-coded header heights are broken. - Keyboard.
KeyboardAvoidingViewis still fiddly;react-native-keyboard-controllergives you synchronised, Reanimated-driven keyboard offsets and is worth the dependency for any form-heavy app. - Text scaling. Every layout should survive the largest accessibility font size. Use
allowFontScalingdeliberately and let containers grow instead of clipping. - Two-pane layouts. If a tablet build is in scope at all, decide early whether navigation is stack-only or master/detail. Retrofitting the split later touches every route.
Shadows, borders and the platform gaps
Style parity has improved but is not complete. As of the recent 0.8x releases, boxShadow and filter are supported on both platforms, which finally kills most of the shadowOffset versus elevation branching. Still check:
- Shadows on Android need a background colour on the shadowed view.
overflow: 'hidden'plus rounded corners plus shadows is still the classic combination that renders differently per platform — wrap rather than stack the properties.- Gradients no longer require a library for simple cases (
experimental_backgroundImage/ linear-gradient support is landing), butexpo-linear-gradientremains the safe choice for production today. gap,rowGapandcolumnGapwork and remove a huge amount of margin bookkeeping. Use them.
Keeping it honest over time
A design system rots unless the build enforces it:
- Lint against raw values. A custom ESLint rule banning hex literals and numeric
padding/marginoutsidetheme/is the highest-leverage rule you will write. - Type your tokens.
keyof typeof spaceas the prop type means an invalid spacing value is a compile error. - One catalogue screen. A dev-only route rendering every component in every variant, in both themes, at the largest font size. Cheaper than Storybook and it catches most regressions.
- Visual snapshots of the catalogue in CI, light and dark, if the app is large enough to justify it.
- Delete the old path. A half-finished NativeWind migration is worse than either endpoint. Migrate screen by screen, and track the count of files still on the legacy pattern in the PR template.
Migration order that works
When we take over a codebase with inconsistent styling, we do it in this order, and we ship at every step:
- Extract the tokens that already exist implicitly — grep every hex value and spacing number, cluster them, agree on a scale with design. Expect to find 60 greys that want to be six.
- Introduce the semantic theme layer and swap the five or six most-used primitives (
Text,Button,Card,Screen,Input) to consume it. - Only then choose the styling library, if you need one. By this point the decision is cheap, because components read tokens from one module.
- Convert screen by screen, newest and most-touched first. Leave dead screens on the old pattern until they are deleted.
- Add the lint rules once the new code outnumbers the old, so the build does not go red on day one.
Checklist
- Tokens live in one dependency-free module, ideally generated from design
- Components consume semantic colours, never raw palette entries
- Variants, not free-form style props, on every shared component
- Theme object memoised and isolated from frequently-changing context
- Theme preference read synchronously before first paint; splash and status bar themed
- No module-scope
Dimensions.get; breakpoints named, not device-sniffed - Safe-area insets per edge; edge-to-edge verified on Android
- Layouts survive the largest accessibility text size
-
gapused instead of margin chains;boxShadowinstead of platform branches - Lint rule banning raw colours and magic spacing outside
theme/ - A catalogue screen covering every variant in both themes
- No two styling systems left in the codebase at the end of the migration
Styling work is easy to defer and expensive to defer. If you are about to add dark mode, a tablet layout or a second brand to an existing React Native app — or you want an experienced pair of hands to run the token extraction and migration without stalling feature work — get in touch. It is the same groundwork our New Architecture and Expo migrations depend on.