- Published on
White‑Label Theming and Dynamic Branding in React Native
Thesis
White‑label theming works when the brand layer is a contract, not scattered magic numbers. Define a stable token surface, map it to semantics, and let brand packs override only what the contract allows.
In React Native, dynamic branding has to hold under pressure: instant theme switches, strict accessibility, RTL, offline defaults, and predictable performance on mid‑range devices. The brand should change the look, never the behavior, of components.
Detailed plan
- Theme contract
- Tokens, semantic colors, density, radii
- Runtime switching
- Overlays, remote config, caching
- Components
- Token‑driven primitives; dark/RTL awareness
- Tooling
- Storybook with brand packs; visual diffs in CI
1) Theme contract
The contract starts with raw tokens (palette, typography scale, spacing, radii) and a semantic map (e.g., primary/background/surface/positive/critical). Semantics decouple features from brand specifics and make a11y reviews reproducible. Keep the contract strict; breaking changes require a version bump and migration notes.
2) Runtime switching
Switching brands at runtime should be idempotent and fast. Load a base theme, apply a brand overlay, then memoize the resolved theme. Remote config can point to a CDN JSON for the overlay; cache locally with an etag and fall back cleanly when offline. This keeps startup deterministic and guarentees the UI won’t flicker across screens.
3) Components
Components consume only semantic tokens via hooks/utilities. Lists, buttons, inputs, and surfaces derive colors, radii, and density from the theme. Respect dark mode and RTL: prefer logical properties (start/end) and avoid mirroring icons that carry meaning. With stable keys and memoization, list virtualization stays smooth even under frequent state changes.
4) Tooling
Bundle a Storybook with all brand packs and scenarios. Add per‑brand visual diffs in CI to catch regressions before release. Keep a fixtures directory for color‑contrast assertions and RTL snapshots. This is boring engineering, but it preserves visiblity for designers and QA during rapid iterations.
Snippets to include later
Below are minimal, production‑ready primitives you can extend.
// theme/types.ts
export type ColorScale = {
50: string; 100: string; 200: string; 300: string; 400: string;
500: string; 600: string; 700: string; 800: string; 900: string;
};
export type Palette = {
primary: ColorScale;
secondary: ColorScale;
neutral: ColorScale;
success: ColorScale;
warning: ColorScale;
danger: ColorScale;
background: string;
surface: string;
text: string;
};
export type Typography = {
fontFamily: string;
sizes: { xs: number; sm: number; md: number; lg: number; xl: number };
lineHeights: { tight: number; normal: number; relaxed: number };
};
export type Density = "comfortable" | "compact";
export type Radii = { none: number; sm: number; md: number; lg: number; full: number };
export type ThemeTokens = {
palette: Palette;
typography: Typography;
density: Density;
radii: Radii;
isDark: boolean;
isRTL: boolean;
};
export type SemanticColors = {
background: string;
surface: string;
textPrimary: string;
textSecondary: string;
primary: string;
primaryContrast: string;
positive: string;
warning: string;
critical: string;
};
export type ResolvedTheme = ThemeTokens & { colors: SemanticColors };
// theme/resolve.ts
import { ThemeTokens, ResolvedTheme } from "./types";
export function resolveTheme(tokens: ThemeTokens): ResolvedTheme {
const textPrimary = tokens.isDark ? tokens.palette.neutral[50] : tokens.palette.neutral[900];
const textSecondary = tokens.isDark ? tokens.palette.neutral[200] : tokens.palette.neutral[600];
const colors = {
background: tokens.palette.background,
surface: tokens.palette.surface,
textPrimary,
textSecondary,
primary: tokens.palette.primary[600],
primaryContrast: tokens.isDark ? tokens.palette.neutral[900] : tokens.palette.neutral[50],
positive: tokens.palette.success[600],
warning: tokens.palette.warning[600],
critical: tokens.palette.danger[600],
};
return { ...tokens, colors };
}
// theme/provider.tsx
import React, { createContext, useContext, useMemo } from "react";
import { resolveTheme } from "./resolve";
import type { ThemeTokens, ResolvedTheme } from "./types";
const ThemeContext = createContext<ResolvedTheme | null>(null);
export function ThemeProvider({ base, overlay, children }: {
base: ThemeTokens;
overlay?: Partial<ThemeTokens>;
children: React.ReactNode;
}) {
const merged = useMemo(() => ({ ...base, ...(overlay ?? {}) }), [base, overlay]);
const resolved = useMemo(() => resolveTheme(merged), [merged]);
return <ThemeContext.Provider value={resolved}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ResolvedTheme {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
// components/Button.tsx
import React, { memo } from "react";
import { Pressable, Text, StyleSheet, I18nManager } from "react-native";
import { useTheme } from "../theme/provider";
export const Button = memo(function Button({ title, onPress }: { title: string; onPress: () => void }) {
const theme = useTheme();
const styles = useMemo(() => StyleSheet.create({
root: {
backgroundColor: theme.colors.primary,
borderRadius: theme.radii.md,
paddingVertical: theme.density === "compact" ? 8 : 12,
paddingHorizontal: 16,
flexDirection: I18nManager.isRTL ? "row-reverse" : "row",
alignItems: "center",
justifyContent: "center",
},
label: {
color: theme.colors.primaryContrast,
fontFamily: theme.typography.fontFamily,
fontSize: theme.typography.sizes.md,
lineHeight: theme.typography.lineHeights.normal,
},
}), [theme]);
return (
<Pressable accessibilityRole="button" onPress={onPress} style={styles.root}>
<Text style={styles.label}>{title}</Text>
</Pressable>
);
});
// runtime/brands.ts
import type { ThemeTokens } from "../theme/types";
export type BrandId = "default" | "acme" | "contoso";
export const baseTheme: ThemeTokens = {
palette: {
primary: { 50: "#eef2ff", 100: "#e0e7ff", 200: "#c7d2fe", 300: "#a5b4fc", 400: "#818cf8", 500: "#6366f1", 600: "#4f46e5", 700: "#4338ca", 800: "#3730a3", 900: "#312e81" },
secondary: { 50: "#fdf2f8", 100: "#fce7f3", 200: "#fbcfe8", 300: "#f9a8d4", 400: "#f472b6", 500: "#ec4899", 600: "#db2777", 700: "#be185d", 800: "#9d174d", 900: "#831843" },
neutral: { 50: "#f9fafb", 100: "#f3f4f6", 200: "#e5e7eb", 300: "#d1d5db", 400: "#9ca3af", 500: "#6b7280", 600: "#4b5563", 700: "#374151", 800: "#1f2937", 900: "#111827" },
success: { 50: "#ecfdf5", 100: "#d1fae5", 200: "#a7f3d0", 300: "#6ee7b7", 400: "#34d399", 500: "#10b981", 600: "#059669", 700: "#047857", 800: "#065f46", 900: "#064e3b" },
warning: { 50: "#fffbeb", 100: "#fef3c7", 200: "#fde68a", 300: "#fcd34d", 400: "#fbbf24", 500: "#f59e0b", 600: "#d97706", 700: "#b45309", 800: "#92400e", 900: "#78350f" },
danger: { 50: "#fef2f2", 100: "#fee2e2", 200: "#fecaca", 300: "#fca5a5", 400: "#f87171", 500: "#ef4444", 600: "#dc2626", 700: "#b91c1c", 800: "#991b1b", 900: "#7f1d1d" },
background: "#ffffff",
surface: "#ffffff",
text: "#111827",
},
typography: {
fontFamily: "System",
sizes: { xs: 12, sm: 14, md: 16, lg: 18, xl: 22 },
lineHeights: { tight: 16, normal: 20, relaxed: 24 },
},
density: "comfortable",
radii: { none: 0, sm: 4, md: 8, lg: 12, full: 999 },
isDark: false,
isRTL: false,
};
export const brandOverlays: Record<BrandId, Partial<ThemeTokens>> = {
default: {},
acme: {
palette: { primary: { 50: "#eff6ff", 100: "#dbeafe", 200: "#bfdbfe", 300: "#93c5fd", 400: "#60a5fa", 500: "#3b82f6", 600: "#2563eb", 700: "#1d4ed8", 800: "#1e40af", 900: "#1e3a8a" } } as any,
},
contoso: {
palette: { primary: { 50: "#f0fdf4", 100: "#dcfce7", 200: "#bbf7d0", 300: "#86efac", 400: "#4ade80", 500: "#22c55e", 600: "#16a34a", 700: "#15803d", 800: "#166534", 900: "#14532d" } } as any,
density: "compact",
},
};
A contract‑first theme scales across brands without forking behavior. It’s predictable to test and simple to reason about in incidents. Personal note: implementing overlays and resolution in small steps was low‑risk and moved fast.
KPIs
- Time to create a new brand (from palette JSON to functional UI)
- Visual diff failures caught pre‑release (per brand, per component)
- A11y contrast checks passing across brands and modes
- App cold start variance when switching brand at runtime
- RN perf counters: dropped frames during theme switch, list FPS under load