references/styling.md
Styling, tokens, and theming
Pick one styling approach per studio, not per project
| Library | Take |
|---|---|
StyleSheet | Always correct, never fast to work in. Fine for a small app, painful for a themed design system. |
| Unistyles | The default here. StyleSheet-shaped API, real theming, variants, breakpoints, and style resolution in C++ so it stays close to raw StyleSheet performance. |
| NativeWind | Choose this instead when the team also ships Tailwind on web. The shared vocabulary is worth real money across a studio, at some runtime cost. |
| Tamagui | Powerful, heavy, opinionated. Justified only if you are adopting its component library wholesale. |
| Uniwind | Tailwind syntax compiled at build time, from the Unistyles authors. Promising; watch it, do not bet a client engagement on it yet. |
Whatever you pick, the tier rules do not change. Tokens are still values, atoms still carry no outer margin, and templates still own screen padding.
Tokens: primitive, then semantic
Two layers, and components only ever touch the second one.
// ui/tokens/colors.ts
const palette = {
slate900: '#0B1220', slate600: '#475569', slate100: '#F1F5F9',
blue600: '#2563EB', blue100: '#DBEAFE',
red600: '#DC2626', green600: '#16A34A', amber500: '#F59E0B',
white: '#FFFFFF', black: '#000000',
} as const;
export const lightColors = {
bg: palette.white,
bgElevated: palette.slate100,
fg: palette.slate900,
fgMuted: palette.slate600,
accent: palette.blue600,
accentFg: palette.white,
danger: palette.red600,
success: palette.green600,
warning: palette.amber500,
border: palette.slate100,
} as const;
Components import colors.danger, never palette.red600. That indirection is the entire reason white-labelling a client app is a token file swap rather than a search-and-replace across four hundred files. It is also what makes dark mode a second semantic map instead of a conditional in every component.
Semantic names describe role, not appearance. accent, not blue. fgMuted, not grey. A client who rebrands to green should not leave you with a token called blue600 that renders green.
The other scales
export const spacing = { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16, 5: 20, 6: 24, 8: 32, 10: 40, 12: 48 } as const;
export const radius = { none: 0, sm: 4, md: 8, lg: 16, full: 9999 } as const;
export const duration = { instant: 0, fast: 150, base: 250, slow: 400 } as const;
Spacing is a scale with a step, not free numbers. The value of the scale is that it is restrictive: a designer asking for 13px gets 12, the layout stays rhythmic, and nobody has to decide.
Typography is a named set, not loose sizes, because sizes without matching line heights produce inconsistent vertical rhythm:
export const typography = {
displayLg: { fontSize: 34, lineHeight: 40, fontWeight: '700', letterSpacing: -0.5 },
title: { fontSize: 20, lineHeight: 26, fontWeight: '600' },
body: { fontSize: 16, lineHeight: 24, fontWeight: '400' },
caption: { fontSize: 13, lineHeight: 18, fontWeight: '400' },
} as const;
Then <Text variant="body">, and no component ever names a font size.
No raw values below the token layer. A hex code, a font size, or a spacing number anywhere in atoms/, molecules/, features/ is a review rejection. That single rule is what makes a theme swap trustworthy.
Icons: never emoji
Do not use emoji as icons. Not in buttons, tab bars, list rows, empty states, status indicators, or headers.
Emoji render from the platform font, which means:
- They look different on iOS, on Android, and across OS versions, so the same
screen is off-brand on half the install base.
- They cannot take a colour, a stroke weight, or a size that matches the rest of
the icon set.
- Screen readers announce their Unicode name ("grinning face with smiling
eyes"), which is rarely what the control does.
- They cannot be swapped when a client rebrands.
Use a real icon set: @expo/vector-icons, lucide-react-native, or the client's own SVGs through react-native-svg. Wrap it in one Icon atom with a typed name, so the set is swappable in one file and autocomplete lists the available icons:
export function Icon({ name, size = 20, color = 'fg' }: IconProps) { ... }
The same applies to copy: emoji do not belong in labels, empty states, or push notifications either.
Theming
// ui/theme/themes.ts
export const themes = {
light: { colors: lightColors, spacing, radius, typography, duration },
dark: { colors: darkColors, spacing, radius, typography, duration },
} as const;
export type AppTheme = (typeof themes)['light'];
Rules:
- One
useTheme(), and it is the only way a component reads tokens. - Follow the system by default, with an explicit override persisted in MMKV.
Three states: system, light, dark. A two-state toggle cannot express "follow the system", and users notice.
- Theme the navigation container too. An unthemed navigator flashes white on
every push in dark mode, and it is the most visible polish bug in the app.
- A white-label client is a themes entry, not a fork. If a client needs more
than a token swap plus a logo asset, that is a product difference and it belongs in a feature flag, not in the theme.
Component style conventions
Variants over boolean props. <Button variant="danger" size="sm" />, not <Button isDanger isSmall />. Booleans multiply into invalid combinations (isDanger plus isGhost) that TypeScript cannot rule out.
style prop last. Merge the caller's override after your own styles, or the escape hatch does not work and someone will fork the component instead.
No outer margin, ever. Spacing between siblings is the parent's job. Give ui/atoms/Stack.tsx a gap prop backed by the spacing scale and use it everywhere. This one convention removes most layout arguments.
Hit targets are 44 points minimum. Small icons get hitSlop, not bigger padding, so the visual size stays right.
Respect the OS. allowFontScaling stays on for body copy. If dynamic type breaks your layout, the layout is too rigid; fix the layout rather than disabling the accessibility feature.
Safe areas belong to templates. An atom or molecule that imports useSafeAreaInsets has taken on a screen-level concern and will be wrong inside a modal or a sheet.