Theme tokens
Semantic palettes per appearance, explicit resolution, one theme hook.
Every app converges on the same shape: semantic color tokens defined per appearance, a pure resolution function mapping in-app preference + system scheme to an effective scheme, and exactly one hook delivering the active palette. Brand values differ per app; the shape does not.
When to use
- Any app rendering in both light and dark appearances, or planning to.
- Screens using translucent materials (
expo-glass-effect), where text needs dedicated glass tokens with contrast targets — solid-surface tokens wash out on glass.
When not to use
- A single-appearance prototype with no dark-mode plan — flat tokens (like the
showcase’s own
src/theme/colors.ts) are enough until the second appearance arrives. - Brand exploration itself — token values are a design task; this pattern covers only the structure that holds them.
File map
src/theme/palettes.ts # AppTheme type + lightTheme/darkTheme (or colors/darkColors)
src/theme/appearance.ts # pure resolveAppearance(mode, system) — no RN imports
src/theme/glass.ts # glass-only tokens or capability flags, separate file
src/theme/<scales>.ts # spacing, radius, typography, shadows (only what you use)
src/state/*-provider.tsx # ThemeProvider + useAppTheme() (or useTheme())
Key excerpts (adapted)
One semantic type, two palettes — names are the contract, hexes are brand:
export type AppTheme = {
background: string;
card: string;
border: string;
text: string;
secondaryText: string;
accent: string;
accentSoft: string;
onAccent: string;
success: string;
danger: string;
};
Resolution is pure and explicit wins — “auto” follows the OS:
export function resolveAppearance(
mode: 'auto' | 'light' | 'dark' | undefined,
system: 'light' | 'dark' | 'unspecified' | null | undefined,
): 'light' | 'dark' {
if (mode === 'light') return 'light';
if (mode === 'dark') return 'dark';
return system === 'dark' ? 'dark' : 'light';
}
One hook delivers the active palette; components never branch on appearance:
const { isDark, colors } = useAppTheme();
<Text style={[styles.title, { color: colors.text }]}>Title</Text>
Glass gets its own tokens, never the solid-surface set:
export const glassText = {
light: { title: '#111815', body: '#202C26', subtext: '#2D3B34', /* … */ },
dark: { title: '#F4F7F5', body: 'rgba(244, 247, 245, 0.94)', subtext: '#D2E0D8', /* … */ },
} as const;
Live demo
Open the (patterns)/theme-tokens route in the showcase/ app: an
appearance switch (system / light / dark), the effective-scheme resolution,
and a swatch grid of a neutral semantic palette in both appearances. Expo Go
is enough.
Anti-patterns
- A static exported
themeobject (“kept for non-UI logic”) that freezes one appearance — always resolve through the hook so dark mode adapts. - Hardcoding colors in
StyleSheet.create— static styles can’t see the theme; inject dynamic colors at render time. - Reusing solid-surface secondary text on glass — frosted luminance washes it out; use the glass subtext token.
PlatformColor/Color.ios.*in themed views — they track the OS, not the in-app preference, and desync the two.- Omitting
colorSchemeon glass surfaces — it defaults to the OS appearance instead of the user’s in-app theme. - Splitting tokens across nine scale files before the app needs them — start with palettes + appearance, add spacing/typography scales on demand.
Provenance
Application labels are anonymized because the production source checkouts are private. They document observed SDK versions and file shapes, not publicly reproducible sources.
- Production Expo 57 app B
src/theme/*:AppTheme+ palettes, pureresolveAppearanceinappearance.ts, capability flags inglass.ts,appearance-provider.tsxdelivery (including the deprecated-static-export mistake this pattern now forbids). - Production Expo 57 app C
src/theme/colors.ts+DESIGN.md§2–§5: dual palettes,getThemeColors/getGlassColors,glassTextcontrast table, Rules 1–4. - Production Expo 53 app A
src/theme/*: full scale system (spacing, radius, typography, shadows, zIndex) behind a singleThemeinterface anduseTheme()hook.