Theming Patterns
Light/dark mode, multi-brand theming, and advanced theme strategies
TypeStyles uses CSS custom properties for theming, making it flexible and powerful. This guide covers common theming patterns.
Theme surfaces
tokens.createTheme(name, config) registers a theme surface: a stable class name theme-{name} whose custom properties override token values for that subtree.
config.base— Token overrides always applied on.theme-{name}(your usual light / brand default).config.modes— Manual list of{ id, overrides, when }layers (seetokens.when.*).config.colorMode— Preset mode layers fromtokens.colorMode.*(media-only dark, attribute toggles, light/dark/system, etc.).
Provide modes or colorMode, not both. Overrides use the same nested shape as tokens.create (nested keys become hyphenated --namespace-key variables).
The return value is a ThemeSurface: { className, name }, with String(surface) and template literals resolving to className. In React, pass surface.className (or String(surface)) to className props.
For dark only when the OS prefers dark, use tokens.createDarkMode(name, overrides) or colorMode: tokens.colorMode.mediaOnly({ dark: … }).
See Tokens for the core API. The THEME.md file at the repository root documents conditions, presets, and cascade ordering in full.
Try a minimal light/dark surface in the live example below — toggle Dark to see the theme class, token overrides, and emitted CSS change together.
Light / dark theme surface
Read-only live output. Toggle variants to see the preview, DOM classes, and emitted CSS update together.
Source · tokens.ts
export const color = tokens.create('color', {
text: '#111827',
surface: '#ffffff',
primary: '#0066ff',
});
export const darkTheme = tokens.createTheme('dark', {
base: {
color: {
text: '#e0e0e0',
surface: '#1a1a2e',
primary: '#66b3ff',
},
},
});
// Apply theme-dark on a wrapper:
<div className={darkTheme.className}>
<div className={card.base}>Themed surface</div>
</div>
DOM
<div class="demo-card-base">…</div>
Emitted CSS
.theme-theming-light-dark-demo-dark {
--theming-light-dark-demo-theme-text: #e0e0e0;
--theming-light-dark-demo-theme-textMuted: #9ca3af;
--theming-light-dark-demo-theme-surface: #1a1a2e;
--theming-light-dark-demo-theme-primary: #66b3ff;
}
.demo-card-base {
padding: 16px 20px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--theme-text) 12%, transparent);
background-color: var(--theme-surface);
color: var(--theme-text);
}
Generating a theme from one accent color
The design-system example ships createColorTheme — a semantic mapper that turns a single
hex accent into light and dark DesignColorValues using the core typestyles/color-scale
math (OKLCH ramps + WCAG contrast checks).
import { createColorTheme } from '@examples/design-system';
const { light, dark } = createColorTheme({
accent: '#0064E0',
neutralStyle: 'neutral', // 'neutral' | 'cool' | 'warm'
contrast: 'standard', // 'standard' | 'high'
});
// Plug into createDesignTheme / tokens.createTheme mode overrides:
export const brandTheme = createDesignTheme({
name: 'brand',
light: { color: light, syntax: defaultLightSyntaxValues },
dark: { color: dark, syntax: defaultDarkSyntaxValues },
});
Layering: typestyles/color-scale is vocabulary-agnostic (parseColor, generateRamp,
contrastRatio). createColorTheme lives in examples/design-system and decides which ramp
step maps to background.app, accent.default, and so on. Derived tokens such as
accent.subtle and text.disabled still come from the existing color-mix() machinery in
tokens/index.ts — createColorTheme only fills the base semantic slots.
Core API (usable without the design-system vocabulary):
import { parseColor, generateRamp, contrastRatio } from 'typestyles/color-scale';
const accent = parseColor('#0064E0'); // { l, c, h } in OKLCH units
const ramp = generateRamp({ hue: accent.h, chroma: accent.c }); // 10-step OKLCH strings
contrastRatio('#000', '#fff'); // 21
Dev-mode contrast warnings (console.warn) fire when generated pairs fall below 4.5:1
(standard) or 7:1 (high); they never throw.
Generating type, motion, and radius scales
The typestyles/token-scale subpath does for numeric ladders what color-scale does for
color: it turns 2-3 numbers into a whole scale, so a theme author writes { base, ratio }
instead of hand-picking every step. Like color-scale, it is vocabulary-agnostic — the
generators return plain numbers and never see names like fontSize, radius, or fast;
zipping your own step names onto the output is your design system's concern.
Reach for each generator by shape:
generateGeometricScale— multiplicative ladders where each step is a ratio of the last (font sizes, spacing that grows faster at the top).stepsare signed integer offsets from the base:value(offset) = base * ratio ** offset.generateLinearScale— even, grid-based ladders (radius steps on a 4px grid).stepsare ordinal multipliers:value(step) = base * step * multiplier.expandDurationBand— one motion anchor expanded into a{ min, base, max }band:min = base * ratio,max = base / ratio, rounded to the nearest 5ms by default so computed bands look hand-picked instead of visibly computed (no93.75ms).
import {
generateGeometricScale,
generateLinearScale,
expandDurationBand,
} from 'typestyles/token-scale';
// Font-size ladder: major third anchored at 16 (offset 0 is always exactly base).
generateGeometricScale({ base: 16, ratio: 1.25, steps: [-2, -1, 0, 1, 2, 3, 4] });
// → [10, 13, 16, 20, 25, 31, 39]
// Radius ladder on a 4px grid.
generateLinearScale({ base: 4, multiplier: 1, steps: [1, 2, 3, 4, 6] });
// → [4, 8, 12, 16, 24]
// One duration anchor → a min/base/max band (call once per named anchor).
expandDurationBand({ base: 150, ratio: 0.625 });
// → { min: 95, base: 150, max: 240 }
Outputs are unitless — append px/rem/ms yourself when zipping the numbers into your
own tokens.create() calls. All three generators round to whole numbers by default; the
array generators accept a round override and expandDurationBand accepts a roundTo
granularity for callers that want different precision.
Basic light/dark mode
Creating a theme
// tokens.ts
import { tokens } from 'typestyles';
// Define your base tokens
export const color = tokens.create('color', {
// Light mode defaults
text: '#111827',
textMuted: '#6b7280',
surface: '#ffffff',
surfaceRaised: '#f9fafb',
surfaceSunken: '#f3f4f6',
border: '#e5e7eb',
primary: '#0066ff',
primaryHover: '#0052cc',
});
// Create dark theme override (class theme-dark)
export const darkTheme = tokens.createTheme('dark', {
base: {
color: {
text: '#e0e0e0',
textMuted: '#9ca3af',
surface: '#1a1a2e',
surfaceRaised: '#25253e',
surfaceSunken: '#16162a',
border: '#3f3f5c',
primary: '#66b3ff',
primaryHover: '#3399ff',
},
},
});
Applying the theme
// App.tsx
import { darkTheme } from './tokens';
function App() {
const [isDark, setIsDark] = useState(false);
return (
<div className={isDark ? darkTheme.className : ''}>
<button onClick={() => setIsDark(!isDark)}>Toggle theme</button>
<PageContent />
</div>
);
}
Persisting theme preference
// hooks/useTheme.ts
import { useState, useEffect } from 'react';
import { darkTheme } from '../tokens';
export function useTheme() {
const [isDark, setIsDark] = useState(() => {
// Check localStorage first
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme');
if (stored) return stored === 'dark';
// Fall back to system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
});
useEffect(() => {
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// Optional: Update meta theme-color
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', isDark ? '#1a1a2e' : '#ffffff');
}
}, [isDark]);
return {
isDark,
themeClass: isDark ? darkTheme.className : '',
toggle: () => setIsDark(!isDark),
};
}
System preference detection
CSS-only approach (no flash)
// tokens.ts
export const color = tokens.create('color', {
text: '#111827',
surface: '#ffffff',
// ... other tokens
});
export const darkTheme = tokens.createTheme('dark', {
base: {
color: {
text: '#e0e0e0',
surface: '#1a1a2e',
},
},
});
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<script>
// Prevent flash of wrong theme
(function () {
const theme =
localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (theme === 'dark') {
document.documentElement.classList.add('theme-dark');
}
})();
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
// App.tsx
import { darkTheme } from './tokens';
function App() {
const [isDark, setIsDark] = useState(() =>
document.documentElement.classList.contains(darkTheme.className),
);
const toggleTheme = () => {
const newTheme = !isDark;
setIsDark(newTheme);
if (newTheme) {
document.documentElement.classList.add(darkTheme.className);
} else {
document.documentElement.classList.remove(darkTheme.className);
}
};
return <div className={isDark ? darkTheme.className : ''}>{/* app content */}</div>;
}
Dark from media query only
If you do not toggle a class yourself and only want dark tokens when prefers-color-scheme: dark matches:
import { tokens } from 'typestyles';
const light = { color: { text: '#111827', surface: '#ffffff' } };
const dark = { color: { text: '#e5e7eb', surface: '#0f172a' } };
export const appTheme = tokens.createTheme('app', {
base: light,
colorMode: tokens.colorMode.mediaOnly({ dark }),
});
Apply appTheme.className once on your root; dark overrides apply automatically via CSS.
Multi-brand theming
Different themes for different contexts
// themes.ts
import { tokens } from 'typestyles';
// Define base tokens structure
const baseTokens = {
color: {
primary: '',
secondary: '',
text: '',
surface: '',
},
space: {
sm: '8px',
md: '16px',
lg: '24px',
},
};
// Brand A theme
export const brandA = tokens.createTheme('brand-a', {
base: {
color: {
primary: '#0066ff',
secondary: '#6b7280',
text: '#111827',
surface: '#ffffff',
},
},
});
// Brand B theme
export const brandB = tokens.createTheme('brand-b', {
base: {
color: {
primary: '#10b981',
secondary: '#f59e0b',
text: '#1f2937',
surface: '#fafafa',
},
},
});
// Brand C theme
export const brandC = tokens.createTheme('brand-c', {
base: {
color: {
primary: '#ef4444',
secondary: '#8b5cf6',
text: '#0f172a',
surface: '#f8fafc',
},
},
});
Applying brand themes
// App.tsx
import { brandA, brandB, brandC } from './themes';
const brands = {
a: brandA,
b: brandB,
c: brandC,
};
function App({ brandId }) {
const surface = brands[brandId] || brandA;
return (
<div className={surface.className}>
<PageContent />
</div>
);
}
Nested themes
Themes can be nested for scoped theming:
import { brandA, brandB } from './themes';
function App() {
return (
<div className={brandA.className}>
<Header /> {/* Uses brandA colors */}
<main>
<div className={brandB.className}>
<Widget /> {/* Uses brandB colors */}
</div>
</main>
<Footer /> {/* Uses brandA colors */}
</div>
);
}
CSS custom properties cascade naturally, so the inner theme overrides only affect its subtree.
Component-specific themes
Isolated component theming
// components/Chart/Chart.tokens.ts
import { tokens } from 'typestyles';
// Chart-specific tokens that don't affect the rest of the app
export const chartTheme = tokens.createTheme('chart', {
base: {
color: {
primary: '#0066ff',
secondary: '#10b981',
tertiary: '#f59e0b',
quaternary: '#ef4444',
grid: '#e5e7eb',
axis: '#6b7280',
},
},
});
// components/Chart/Chart.tsx
import { chartTheme } from './Chart.tokens';
export function Chart({ data }) {
return (
<div className={chartTheme.className}>
<svg className={chart('svg')}>{/* Chart uses chart-specific color tokens */}</svg>
</div>
);
}
Seasonal/time-based themes
Time-aware theming
// themes/seasonal.ts
import { tokens } from 'typestyles';
export const holidayTheme = tokens.createTheme('holiday', {
base: {
color: {
primary: '#c41e3a', // Holiday red
secondary: '#165b33', // Holiday green
accent: '#ffd700', // Gold
},
},
});
export const springTheme = tokens.createTheme('spring', {
base: {
color: {
primary: '#88c999',
secondary: '#f4a460',
accent: '#ffb6c1',
},
},
});
// hooks/useSeasonalTheme.ts
import { holidayTheme, springTheme } from '../themes/seasonal';
export function useSeasonalTheme(): string | undefined {
const now = new Date();
const month = now.getMonth();
// Holiday season: December
if (month === 11) {
return holidayTheme.className;
}
// Spring: March-May
if (month >= 2 && month <= 4) {
return springTheme.className;
}
return undefined; // Use default theme
}
Advanced theme composition
Partial themes
// themes/semantics.ts
import { tokens } from 'typestyles';
// Semantic color tokens
export const successTheme = tokens.createTheme('success', {
base: {
color: {
primary: '#10b981',
primaryHover: '#059669',
},
},
});
export const warningTheme = tokens.createTheme('warning', {
base: {
color: {
primary: '#f59e0b',
primaryHover: '#d97706',
},
},
});
export const dangerTheme = tokens.createTheme('danger', {
base: {
color: {
primary: '#ef4444',
primaryHover: '#dc2626',
},
},
});
// components/Alert/Alert.tsx
import { successTheme, warningTheme, dangerTheme } from '../../themes/semantics';
const alertThemeClasses = {
success: successTheme.className,
warning: warningTheme.className,
danger: dangerTheme.className,
};
export function Alert({ type, children }) {
return <div className={alertThemeClasses[type]}>{children}</div>;
}
Token layering
// tokens/layers.ts
import { tokens } from 'typestyles';
// Layer 1: Primitives
const primitives = tokens.create('primitives', {
// Raw values
blue500: '#0066ff',
blue600: '#0052cc',
gray500: '#6b7280',
});
// Layer 2: Semantic tokens
const color = tokens.create('color', {
// Reference primitives
primary: primitives.blue500,
primaryHover: primitives.blue600,
text: '#111827',
});
// Layer 3: Component tokens
const button = tokens.create('button', {
// Reference semantic tokens
backgroundColor: color.primary,
backgroundColorHover: color.primaryHover,
textColor: '#ffffff',
});
Light / dark / system on data-*
When the user can pick light, dark, or system, and light must win over system dark, use tokens.colorMode.systemWithLightDarkOverride:
import { tokens } from 'typestyles';
const light = { color: { text: '#111827', surface: '#ffffff' } };
const dark = { color: { text: '#e5e7eb', surface: '#0f172a' } };
export const shell = tokens.createTheme('shell', {
base: light,
colorMode: tokens.colorMode.systemWithLightDarkOverride({
attribute: 'data-color-mode',
values: { light: 'light', dark: 'dark', system: 'system' },
scope: 'ancestor',
light,
dark,
}),
});
Set data-color-mode on html (or another ancestor); apply shell.className on your themed subtree.
Accessibility considerations
High contrast mode
// themes/accessibility.ts
import { tokens } from 'typestyles';
export const highContrastTheme = tokens.createTheme('high-contrast', {
base: {
color: {
text: '#000000',
surface: '#ffffff',
primary: '#0000ff',
border: '#000000',
},
},
});
// hooks/useAccessibility.ts
export function useHighContrast() {
const [isHighContrast, setIsHighContrast] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-contrast: high)');
setIsHighContrast(mediaQuery.matches);
const handler = (e) => setIsHighContrast(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return isHighContrast;
}
Respect user preferences
// tokens.ts
import { tokens } from 'typestyles';
export const color = tokens.create('color', {
text: '#111827',
// ... other tokens
});
export const darkTheme = tokens.createTheme('dark', {
base: {
color: {
text: '#e0e0e0',
// ... other overrides
},
},
});
// Optional: separate surface for motion tokens, etc.
export const reducedMotionTheme = tokens.createTheme('reduced-motion', {
base: {
/* motion-related overrides */
},
});
// App.tsx
import { darkTheme } from './tokens';
function App() {
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const themeClasses = [prefersDark && darkTheme.className, prefersReducedMotion && 'reduce-motion']
.filter(Boolean)
.join(' ');
return (
<div className={themeClasses}>
<PageContent />
</div>
);
}
Theme switching transitions
Smooth theme transitions
/* Add to your global CSS or a style element */
html,
body,
* {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
Or with typestyles global CSS:
import { global } from 'typestyles';
global.style('html', {
transition: 'background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease',
});
Use a scoped global from createTypeStyles({ layers, globalLayer }) when you emit globals into a cascade layer (see Cascade layers).
Prevent flash during theme switch
import { useEffect, useState } from 'react';
import { darkTheme } from './tokens';
function ThemeProvider({ children }) {
const [themeClass, setThemeClass] = useState('');
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Read theme from localStorage or system preference
const savedTheme = localStorage.getItem('theme');
setThemeClass(savedTheme === 'dark' ? darkTheme.className : '');
setIsReady(true);
}, []);
// Prevent rendering until theme is determined
if (!isReady) {
return null; // or a loading spinner
}
return <div className={themeClass}>{children}</div>;
}
SSR with themes
Server-side theme detection
// server.ts
import { collectStyles } from 'typestyles/server';
import { darkTheme } from './tokens';
app.get('/', (req, res) => {
// Detect theme from cookie or user preference
const themeCookie = req.cookies.theme;
const isDark = themeCookie === 'dark';
const htmlClass = isDark ? darkTheme.className : '';
const { html, css } = collectStyles(() =>
renderToString(
<div className={htmlClass}>
<App />
</div>
)
);
res.send(`
<!DOCTYPE html>
<html class="${htmlClass}">
<head>
<style id="typestyles">${css}</style>
</head>
<body>
<div id="root">${html}</div>
</body>
</html>
`);
});
Best practices
- Use semantic token names -
primary,surface,textinstead ofblue,white,black - Define dark mode alongside light mode - Keep them in sync
- Test both themes - Use visual regression for both modes
- Respect system preferences - Default to
prefers-color-scheme - Provide user override - Let users choose independently of system
- Store preference - Use localStorage to remember user choice
- Avoid theme flash - Set theme class before first paint
- Use CSS custom properties - They cascade naturally for nested themes