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.colorMode— Optional{ light?, dark? }patches deep-merged intobaseand compiled tolight-dark()whencolorModesis configured (see Tokens — Mode-aware token leaves).config.modes— Manual list of{ id, overrides, when }layers (seetokens.when.*), including spreads oftokens.colorMode.*preset arrays.
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 modes: 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">…</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 {
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,
modes: 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,
modes: 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.
Mode-aware token values (light-dark() on custom properties)
When you register colorModes on createTypeStyles or createTokens, token leaves can use
{ light, dark } directly in tokens.create() and structured colorMode: { light, dark }
patches on tokens.createTheme(). Color-compatible values compile to light-dark() on --*
variables; shadow-like shorthands get a dark override rule instead.
import { colorModes, createTypeStyles } from 'typestyles';
const { tokens } = createTypeStyles({ scopeId: 'app', colorModes });
const brand = tokens.create('brand', {
accent: { light: '#111', dark: '#eee' },
});
const theme = tokens.createTheme('acme', {
base: { color: { text: '#111' } },
colorMode: { dark: { color: { text: '#eee' } } },
});
This is separate from style-level { light, dark } on component properties (see
Mode-aware property values below) and from
conditional modes layers driven by tokens.when.*. See
Tokens — Mode-aware token leaves for the full API.
Building createTheme({ from, tokens }) wrappers
Design systems often expose a higher-level createDesignTheme({ from, tokens, colorMode })
that merges a preset with user overrides before calling tokens.createTheme(). Token refs from
tokens.declare() are proxy objects — they cannot be cloned with structuredClone and will not
round-trip through ad-hoc deep merges.
Use the exported helpers instead. Pass leaf refs (semantic.accent.default), not branch
proxies (semantic.accent):
import { createTypeStyles, mergeThemeOverrides } from 'typestyles';
const { tokens } = createTypeStyles({ scopeId: 'app' });
const preset = { color: { text: '#111827', accent: { default: '#0066ff' } } };
export function createDesignTheme(options: {
from?: typeof preset;
tokens?: typeof preset;
colorMode?: { light?: typeof preset; dark?: typeof preset };
}) {
const base = mergeThemeOverrides(options.from ?? {}, options.tokens);
const light = options.colorMode?.light
? mergeThemeOverrides(base, options.colorMode.light)
: undefined;
const dark = options.colorMode?.dark
? mergeThemeOverrides(base, options.colorMode.dark)
: undefined;
return tokens.createTheme('app', {
base,
colorMode: { light, dark },
});
}
mergeThemeOverrides(base, patch?) — deep-merge two override trees. Plain objects merge
recursively; arrays and primitives from patch replace base. Leaf token refs stringify to
var(--…); the result is always a deep clone.
cloneThemeValues(values) — deep-clone a value tree with the same ref serialization when
you need an isolated copy outside of mergeThemeOverrides.
Condition scopes: self, ancestor, descendant
tokens.when.attr(name, value, { scope }) and tokens.when.className(name, { scope }) accept three scope values describing where the marker lives relative to the theme root:
'self'— the marker is on the theme root itself:.theme-app[data-mode="dark"].'ancestor'— the marker is on an ancestor of the theme root (e.g.data-color-modeonhtml):[data-mode="dark"] .theme-app.'descendant'— the marker is on a descendant of the theme root, somewhere inside the themed subtree:.theme-app [data-mode="dark"].
Fixed-tone surfaces with scope: 'descendant'
Sometimes a specific element should render with a fixed tone regardless of the page's ambient mode — a toast that is always dark even on a light page, a syntax-highlighted code block that stays dark in light mode. That is not "dark mode": it is a design decision fixed to one element. A descendant-scoped mode expresses exactly that — the marker attribute goes on the element itself, inside the themed subtree:
import { tokens } from 'typestyles';
const light = { color: { text: '#111827', surface: '#ffffff' } };
const dark = { color: { text: '#e5e7eb', surface: '#0f172a' } };
export const app = tokens.createTheme('app', {
base: light,
modes: [
// Ambient light/dark switching, as usual:
...tokens.colorMode.systemWithLightDarkOverride({
attribute: 'data-color-mode',
values: { light: 'light', dark: 'dark' },
scope: 'ancestor',
light,
dark,
}),
// Fixed-tone override for marked elements inside the themed subtree:
{
id: 'surface-dark',
overrides: dark,
when: tokens.when.attr('data-surface', 'dark', { scope: 'descendant' }),
},
],
});
<div class="theme-app">
<p>Follows the ambient light/dark mode…</p>
<div data-surface="dark">…but this toast is always dark.</div>
</div>
The descendant-scoped rule compiles to .theme-app [data-surface="dark"], so any element carrying data-surface="dark" inside the theme gets the dark token values — no matter what prefers-color-scheme or data-color-mode currently say. tokens.colorMode.* presets return plain mode arrays — spread them into modes alongside hand-written entries (as above).
Two properties worth knowing:
- No built-in "reset to ambient". Once a descendant-scoped mode matches, it applies to that whole subtree; a nested child cannot fall back to the ambient mode automatically. Define an explicit mode (e.g.
data-surface="light") if you need a counter-tone. when.not()is not supported on descendant-scoped conditions — a descendant relationship cannot collapse into a single:not()compound selector. In development this logs a warning and emits no rule.
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).
Animating typed tokens with @property
The transition above works because background-color, color, and border-color are already-animatable CSS properties — the browser interpolates the computed value across the style change regardless of how it was derived from var(--…). Plain custom properties themselves don't get that for free: an unregistered --token is just a token stream, so a value that's only ever consumed as raw text — a gradient angle, a filter amount, the custom property itself via transition: --token — snaps discretely instead of animating.
Register the token with
syntaxto make it interpolate.tokens.createandstyles.propertyboth accept{ value, syntax, inherits }and register a typed@propertyrule for the custom property. Once a token has a typedsyntax(<color>,<angle>,<number>, …), the browser knows how to interpolate it directly — including inside values, like gradients, that atransitionon a real CSS property can't reach.
import { tokens, styles } from 'typestyles';
// A typed, animatable custom property — not just a plain token.
const brand = tokens.create('brand', {
angle: { value: '20deg', syntax: '<angle>', inherits: true },
});
const dark = tokens.createTheme('dark', {
base: {
brand: { angle: '260deg' },
},
});
const card = styles.class('card', {
backgroundImage: `conic-gradient(from ${brand.angle.var}, #0066ff, #66b3ff, #0066ff)`,
transition: `${brand.angle.name} 0.6s ease`,
});
Tokens whose value references another token stay typed too. Declare the
schema with tokens.declare, then pass plain values to tokens.create. For
syntax leaves, @property is emitted at declare time with a
syntax-appropriate placeholder initial-value (transparent for <color>,
0 for <number>, and so on), while :root carries the real value — including
color-mix() expressions built from forward refs:
const color = tokens.declare('color', {
accent: { default: { syntax: '<color>', inherits: false } },
accentSubtle: { syntax: '<color>', inherits: false, initial: 'hotpink' },
});
tokens.create(
'color',
{
accent: { default: '#0066ff' },
accentSubtle: `color-mix(in oklch, ${color.accent.default} 24%, white)`,
},
{ decl: color },
);
Toggling dark.className now smoothly rotates the gradient across the theme switch — the browser tweens --brand-angle (brand.angle.name) from 20deg to 260deg frame by frame, then the conic-gradient re-renders each step, because @property told it --brand-angle is an <angle>. Without the registered syntax, the same class swap would snap the gradient to its new angle with no animation at all.
This is a genuine capability gap versus compiler-first tools: StyleX's own documented capability list marks explicit @property output as unsupported ("compiles but invalid CSS output"), so an Astryx/StyleX theme can't emit this pattern — a smoothly animating gradient angle or color token on theme switch is structurally unavailable to it. TypeStyles tokens and styles.property emit real @property rules because they're just CSS, not compiler-owned output. See Theming architecture: TypeStyles vs. StyleX (and Astryx) for the fuller comparison, and styles.property / tokens.create for the full options.
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>
`);
});
Component overrides (two-tier model)
When a theme needs to change how a component looks beyond token overrides, pick the
lightest option that fits. For recipe-shaped bulk restyling (base / variants /
compounds) without writing class names, use styles.override()
first; fall back to Tier 1 vars or Tier 2 CSS when you need a single property or
nested-theme proximity.
Typed component overrides
styles.override(component, config, options?) restyles a styles.component()
return with the same shape as the recipe — variant keys autocomplete from the
recipe type, and you never pass class names. Call it on the same styles
instance that created the component (design systems typically wrap this).
Cross-instance calls — a component from stylesA passed to stylesB.override —
are unsupported: emission uses this instance's sheet, breakpoints, and layer
stack, so selectors may land in the wrong CSS.
With @typestyles/vite in serve mode, modules that call styles.override (or
design-system sugar like createDesignTheme / overrideComponent, including
renamed imports such as createDesignTheme as cdt) get HMR dispose tracking so
theme edits update override CSS without a full reload. Re-registering the same
override keys always replaces the previous CSS. Recipe HMR still preserves
override rules — theme modules own those registrations.
import { createStyles } from 'typestyles';
const styles = createStyles({
layers: ['components', 'overrides'] as const,
});
const button = styles.component(
'button',
{
base: { borderRadius: '6px' },
variants: {
intent: {
primary: { backgroundColor: 'blue' },
ghost: { backgroundColor: 'transparent' },
},
size: { sm: { fontSize: '12px' }, lg: { fontSize: '16px' } },
},
},
{ layer: 'components' },
);
// App-global
styles.override(
button,
{
base: { borderRadius: '999px' },
variants: {
intent: { primary: { textTransform: 'uppercase' } },
},
},
{ layer: 'overrides' },
);
// Theme-scoped (descendant prefix — not CSS `@scope`)
styles.override(
button,
{ base: { boxShadow: 'none' } },
{ selectorPrefix: '.theme-acme', layer: 'overrides' },
);
Works for semantic, bem, template, and attribute naming modes (and flat / slot /
multi-slot recipe shapes). Attribute mode emits selectors like
.button[data-intent="primary"]; put overrides in a later cascade layer so they
beat recipe CSS without fighting specificity. When createStyles({ layers })
includes an "overrides" layer, omitting { layer } defaults to that name —
custom stacks without "overrides" must pass { layer } explicitly.
Override component internal vars
When a recipe exposes themeable surfaces with c.vars() or top-level config vars,
consumers override them with the matching top-level vars block on
styles.override() (typed logical keys) or token/theme CSS on the var host.
const sideNav = styles.component('side-nav', (c) => {
const v = c.vars({
border: { value: '#ccc', syntax: '<color>' as const },
headingColor: '#111',
});
return {
vars: v,
slots: ['root', 'heading'] as const,
base: {
root: { borderColor: v.border.var },
heading: { color: v.headingColor.var },
},
};
});
// sideNav.vars.border.var — same paths consumers override below
styles.override(
sideNav,
{
vars: { border: 'transparent', headingColor: 'var(--brand-heading)' },
base: { root: { margin: '8px', borderRadius: '12px' } },
},
{ selectorPrefix: '.theme-acme', layer: 'overrides' },
);
// => .theme-acme .side-nav { --side-nav-border: transparent; …; margin: 8px; … }
Use vars: v so OverrideConfigFor<typeof sideNav> infers theme keys — no need to repeat
the definition object. c.vars() alone auto-stamps runtime metadata when you omit vars.
Var overrides emit on the var host class (same element that owns default var
declarations from the recipe). They compose with base slot overrides on that host;
if both set the same custom property, base wins.
Mode-aware property values (colorModes)
Register color modes once on the styles instance, then use { light, dark } on color
and image properties — they compile to light-dark():
import { createTypeStyles, colorModes } from 'typestyles';
const { styles } = createTypeStyles({ scopeId: 'var-ui', colorModes });
const button = styles.component('button', {
base: { borderColor: { light: 'red', dark: 'blue' } },
});
styles.override(button, {
base: { color: { light: '#111', dark: '#eee' } },
});
// => border-color: light-dark(red, blue); color: light-dark(#111, #eee)
colorModes from typestyles is ['light', 'dark']. Array order defines
light-dark() arguments: index 0 = light color-scheme, index 1 = dark. Structural
properties (padding, fontWeight, …) should use conditions instead — dev warns if
you pass mode objects on them. v1 supports at most two registered modes.
Conditional override blocks (conditions)
For structural or multi-property mode differences, add conditions on override style
blocks (base, variant options, compounds, slot blocks). Each entry compiles when with
the same tokens.when.* builders theme modes use:
import { conditional } from 'typestyles';
import { when } from 'typestyles'; // or tokens.when from your tokens instance
styles.override(
button,
{
base: {
letterSpacing: '0.02em',
conditions: [
conditional(when.attr('data-mode', 'dark', { scope: 'ancestor' }), {
letterSpacing: '0.06em',
}),
conditional(when.prefersDark, { color: 'white' }, 'dark-text'),
],
},
},
{ selectorPrefix: '.theme-acme', layer: 'overrides' },
);
conditions is reserved — do not put it on styles.component() recipe definitions.
getComponentMeta(component) reads the public __tsMeta blob (namespace, kind,
naming mode, base class(es), per-option selector fragments). Renaming anything in
that metadata is a breaking change — same contract as public class names.
For nested conflicting theme regions, selectorPrefix has the same proximity
footgun as plain descendant CSS — use Tier 1 vars or styles.scope().
Tier 1 — component-scoped CSS custom properties (preferred)
If a component author exposed a property as a CSS custom property (ctx.vars /
c.vars), consumers override it per theme with styles.override({ vars }) (typed
logical keys) or ordinary token/theme CSS on the var host. Custom properties inherit
down the DOM and reset at each .theme-* boundary, so nested themes stay
proximity-correct with no extra tooling.
See Components — expose themeable properties as vars and override internal vars in themes.
Tier 2 — plain CSS against semantic class names
For properties the author did not expose as vars, target the stable semantic class
names from styles.component() (for example button, button--intent-primary).
See Class naming and the public contract.
Non-nested themes: a descendant selector in a later cascade layer is enough:
import { createStyles } from 'typestyles';
const styles = createStyles({
layers: ['components', 'overrides'] as const,
});
const button = styles.component(
'button',
{ base: { padding: '8px 16px' } },
{ layer: 'components' },
);
// In theme setup for `.theme-acme`:
styles.class('.theme-acme .button', { borderRadius: '999px' }, { layer: 'overrides' });
Nested conflicting themes: when two .theme-* regions nest and both override the
same component class, plain selectors tie on specificity and source order wins. Use
styles.scope() so the nearest scoping root wins:
styles.scope({ root: '.theme-beta', to: '.theme-acme', layer: 'overrides' }, 'button', {
backgroundColor: 'rebeccapurple',
'&:hover': { opacity: 0.9 },
});
styles.scope() reuses the same serializeStyle / applyLayerToRules / insertRules
pipeline as other TypeStyles APIs — pseudo-selectors and @media in overrides work
unchanged.
Browser support: @scope ships in Chrome 118+, Firefox 128+, Safari 17.4+. Treat
it as an opt-in escalation for nested conflicts; older browsers can keep Tier 2 plain
selectors and accept the documented load-order caveat.
Attribute variants: use layers, not specificity
Attribute variants intentionally use selectors such as
.button[data-intent="primary"], which are more specific than .button. When
theming an attribute-mode design system, put recipe CSS and overrides in ordered
cascade layers so override precedence is explicit:
const { styles } = createTypeStyles({
mode: 'attribute',
layers: ['tokens', 'components', 'overrides', 'utilities'] as const,
tokenLayer: 'tokens',
});
const button = styles.component(
'button',
{ base: { borderRadius: '6px' } },
{ layer: 'components' },
);
styles.scope({ root: '.theme-acme', layer: 'overrides' }, button.base, { borderRadius: '999px' });
// Prefer styles.override(button, { base: { borderRadius: '999px' } }, { selectorPrefix: '.theme-acme', layer: 'overrides' })
Use components for recipe CSS, overrides for theme or consumer restyles, and
utilities for per-instance intent. A later layer wins without a specificity
escalation. Prefer styles.override() for recipe-shaped restyles (including
attribute variants); plain CSS / styles.scope() remain fine when you only need
a base-class or ad-hoc selector.
Public semantic class names
In semantic naming mode (the default), every class emitted by
styles.component() / styles.class() is a public, semver-guarded surface.
Consumers may target those names in plain CSS, styles.scope(), styles.override(), or any other CSS
tooling. Renaming a namespace or variant key is a breaking change — TypeScript will
not catch a renamed string literal, so publishable design systems should opt into the
@typestyles/no-removed-public-classname
rule and regenerate .typestyles-public-classnames.json deliberately when names change.
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