A cross-platform theme should give product code one semantic vocabulary while allowing iOS and Android to resolve that vocabulary through their native color systems. Components ask for background, text, or primary; they should not branch on the operating system to choose raw colors.
Separate Meaning from Resolution
Define the color roles the product uses before deciding their platform values:
export type AppColors = {
background: string;
surface: string;
text: string;
secondaryText: string;
separator: string;
primary: string;
destructive: string;
};
The role names remain stable across platforms and color schemes. A platform resolver maps them to iOS semantic colors, Android Material colors, or pinned brand values.
Do not name tokens after their appearance, such as gray500 or darkBlue, when product code means secondaryText or link. Appearance changes between themes; meaning should not.
Keep Configuration in One Place
A small theme boundary prevents color decisions from spreading through the app:
theme/
config.ts semantic roles and brand overrides
colors.ts iOS and Android resolvers
ThemeContext.tsx provider and access hooks
config.ts is the day-to-day source of truth. Components read values through useColors() or useTheme(). React Navigation receives a theme derived from the same values so headers, tab bars, transition backgrounds, and screen content do not disagree.
Let Native Colors Stay Dynamic
Native semantic colors change with light mode, dark mode, accessibility settings, and on Android, Material You. Resolve them at render time and subscribe to the current color scheme so mounted screens update when system appearance changes.
With universal Expo UI, a Host can accept a seedColor. Android derives a Material 3 palette, iOS applies it as the SwiftUI tint, and web exposes a generated scale. Use useMaterialColors() inside the Android host when React Native views must share the same palette as Compose children.
Pinned brand colors still need light and dark validation. “Brand color” does not mean “safe on every surface.” Test text, icons, focus states, and disabled states against their actual backgrounds.
Provide One Access Path
const ColorsContext = createContext<AppColors | null>(null);
export function useColors() {
const colors = useContext(ColorsContext);
if (!colors) {
throw new Error("useColors must be used inside ColorsProvider");
}
return colors;
}
Avoid a mix of context colors, imported constants, Platform.select, and inline hex values. Multiple access paths create multiple sources of truth.
Keep platform branching inside the resolver:
export function resolveColors(scheme: ColorSchemeName): AppColors {
return Platform.select({
ios: () => resolveIosColors(scheme),
android: () => resolveAndroidColors(scheme),
default: () => resolveFallbackColors(scheme),
})();
}
The rest of the app only sees AppColors.
Theme Navigation with the App
Navigation surfaces are part of the screen. Derive the router or React Navigation theme from the same tokens:
function getNavigationTheme(colors: AppColors, scheme: ColorSchemeName): Theme {
return {
dark: scheme === "dark",
colors: {
primary: colors.primary,
background: colors.background,
card: colors.surface,
text: colors.text,
border: colors.separator,
notification: colors.destructive,
},
fonts: DefaultTheme.fonts,
};
}
Set the correct dark value from the active scheme. A mismatched value can produce incorrect system bar, transition, and native control behavior even when the visible colors look close.
Test the Whole Matrix
Check more than two screenshots:
- iOS light and dark appearances
- Android light and dark appearances
- Android with and without dynamic color support
- increased contrast or accessibility color settings where supported
- screens containing both React Native and Expo UI native subtrees
- navigation transitions, sheets, system bars, and loading states
The common failure is not a missing token. It is a surface outside the provider or a native subtree using a different scheme.
Checklist
- Product code uses semantic color roles
- Platform mapping lives in one resolver layer
- One provider updates when system appearance changes
- React Navigation derives its theme from the same tokens
- Expo UI hosts receive the intended scheme and seed color
- No screen-level
Platform.selectfor colors - No raw hex values outside theme configuration or exceptional assets
- Brand colors pass contrast checks in light and dark contexts
- Mixed React Native and native UI subtrees share one visual theme
Sources
Check platform behavior against React Native's useColorScheme documentation and Expo UI's current Host and Material colors APIs.