Skip to content

Theming

Stratum’s theme system is built on two core ideas: every component reads from a typed AppTheme interface (never from hardcoded values), and all themes are registered in a single THEME_REGISTRY that everything else derives from automatically.

AppTheme is defined in src/tokens/theme-protocol.ts. Every theme — built-in or custom — must implement this interface. Key sections:

interface AppTheme {
colors: {
// Backgrounds
background: string;
surface: string;
surfaceRaised: string;
// Text
textPrimary: string;
textSecondary: string;
textMuted: string;
textInverse: string;
textOnAccent: string;
// Accent (one hue — ADR-010)
accent: string;
accentHover: string;
accentSubtle: string;
// Signal
success: string; successSubtle: string;
warning: string; warningSubtle: string;
error: string; errorSubtle: string;
// Borders + overlay
border: string; borderStrong: string; borderSubtle: string;
overlay: string;
};
typography: { fontFamily: { heading: string; body: string; mono: string } };
radius: { none: number; sm: number; md: number; lg: number; xl: number; full: number };
border: { thin: number; medium: number; strong: number };
shadow: { none: object; sm: object; md: object; lg: object };
iconWeight: IconWeight;
darkIconWeight?: IconWeight;
buttonShape: 'default' | 'pill';
effects: { blurIntensity: number; glassTint: string; glowColor: string; innerBorderColor: string };
}
ThemebuttonShapeiconWeightradius.mdborder.thin
Slate light/darkpillregular81
Obsidian light/darkdefaultbold02
Quartz light/darkpilllight81

src/tokens/themes/registry.ts is the single source of truth:

export const THEME_REGISTRY: ThemeEntry[] = [
{ name: 'slate', label: 'Slate', light: slateLight, dark: slateDark },
{ name: 'obsidian', label: 'Obsidian', light: obsidianLight, dark: obsidianDark },
{ name: 'quartz', label: 'Quartz', light: quartzLight, dark: quartzDark },
];

Unistyles, ThemeProvider, and all TypeScript types auto-derive from this registry. Adding a new theme is two steps: create the file, add one entry.

Use createTheme() to deep-merge overrides onto a base theme:

import { createTheme } from '@/tokens/themes/createTheme';
import { slateThemeLight, slateThemeDark } from '@/tokens/themes/slate';
export const brandLight = createTheme(slateThemeLight, {
colors: {
accent: '#E11D48',
accentHover: '#BE123C',
accentSubtle: '#FFF1F2',
},
});
export const brandDark = createTheme(slateThemeDark, {
colors: {
accent: '#FB7185',
accentHover: '#F43F5E',
accentSubtle: '#4C0519',
},
});

Then register it:

src/tokens/themes/registry.ts
import { brandLight, brandDark } from './brand';
export const THEME_REGISTRY = [
// ... existing themes
{ name: 'brand', label: 'Brand', light: brandLight, dark: brandDark },
];

That’s it — setTheme('brand') now works.

import { useThemeToggle } from '@/hooks/useThemeToggle';
const { themeName, colorScheme, setTheme, toggleColorScheme } = useThemeToggle();

Theme switching triggers zero re-renders in components — Unistyles observes the registry change and updates styles in place.