Theming
Every component reads color, type, shape and motion from CSS custom properties. Generate a full light and dark color scheme from one seed color, at build time or at runtime.
The baseline theme
css/index.css (or @kyralabs/m3e-tokens/theme.css) sets Material's
baseline purple scheme, the colors of Compose's expressiveLightColorScheme() and
darkColorScheme(). It is light, dark when the system prefers dark, and forced
with data-theme on the root element:
<html lang="en" data-theme="dark"> <!-- or "light"; leave it off to follow the system -->
Add <meta name="color-scheme" content="light dark"> so form controls and
scrollbars follow the scheme too. To let readers switch, set the attribute from a script and
remember the choice; run that script in the <head> so the page never paints in
the wrong scheme first:
// In an inline <script> in <head>:
const saved = localStorage.getItem('theme'); // 'light' | 'dark' | null (system)
if (saved) document.documentElement.dataset.theme = saved;
// From your theme switch:
function setTheme(mode) {
if (mode === 'system') delete document.documentElement.dataset.theme;
else document.documentElement.dataset.theme = mode;
localStorage.setItem('theme', mode);
}
A theme from a seed color
createTheme builds every color role from one seed with Material Color Utilities
0.4.0 and returns the CSS. Its rules are unlayered, so loading them after the library's CSS
overrides the baseline theme:
import { createTheme } from '@kyralabs/m3exp-web/theme';
const { cssText, tokens } = createTheme({ seed: '#6750A4', variant: 'expressive' });
const style = document.createElement('style');
style.textContent = cssText;
document.head.append(style);
tokens.light?.primary; // the resolved roles, e.g. for a canvas or an email template
The home page's theme demo runs exactly this: pick a hue and a variant and the whole site re-themes.
| Option | Values | What it does |
|---|---|---|
seed |
#rgb or #rrggbb | The source color. Required. |
variant |
'tonalSpot' (default), 'expressive', 'vibrant', 'neutral', 'monochrome', 'fidelity', 'content', 'rainbow', 'fruitSalad' | Material Color Utilities' dynamic scheme: how far roles move from the seed's hue and chroma. |
mode |
'system' (default), 'light', 'dark' | system writes both schemes and follows prefers-color-scheme; data-theme overrides it. |
contrast |
-1 to 1, or 'standard' (0), 'medium', 'high' | Contrast level. Spec 2025 treats negative values as 0. |
specVersion |
'2025' (default), '2021' | The color spec (DECISIONS D3); 2025 adds the dim roles. |
motionScheme |
'expressive' (default), 'standard' | Points the spring tokens at one motion scheme. |
coreColors |
secondary, tertiary, neutral, neutralVariant, error | Replace a derived palette with one built from your color. |
overrides |
{ light?: {...}, dark?: {...} } | Per-mode role overrides, e.g. { light: { primary: '#6750a4' } }. |
selector |
string | A single compound selector to scope the theme to. Default :root. |
At build time
The m3e-theme command from @kyralabs/m3e-tokens writes the same CSS,
so no color code ships to the browser:
npx m3e-theme --seed "#6750A4" --variant expressive > theme.css
npx m3e-theme --seed "#6750A4" --mode dark --contrast medium --out theme-dark.css
Flags: --variant, --mode, --contrast,
--spec, --motion, --selector, --out, and the
core colors --secondary, --tertiary, --neutral,
--neutral-variant, --error. Load the file after the library's CSS.
Core colors
Like Material Theme Builder, you can give your own colors for the other palettes. Each keeps the given color's hue and chroma; the variant still maps roles to tones. A red primary on grey surfaces:
createTheme({
seed: '#d84848',
variant: 'fidelity',
coreColors: { neutral: '#808080', neutralVariant: '#808080' },
});
With spec 2025 (the tonalSpot, neutral, vibrant and
expressive variants) the outline and surface-variant roles come from the neutral
palette, so neutralVariant has no effect there. variant: 'fidelity'
keeps the seed closest to how you gave it, like Theme Builder.
Scoped themes
selector applies a theme to part of a page. Its dark rules key off
data-theme on the same element:
const { cssText } = createTheme({ seed: '#00696d', selector: '.promo' });
// .promo { … } and .promo[data-theme="dark"] { … }, plus the system dark rule
Beyond color
Type, shape, motion, elevation and spacing are tokens too:
--m3e-sys-typescale-*, --m3e-sys-shape-*,
--m3e-sys-motion-*, --m3e-sys-elevation-*,
--m3e-sys-space-*, and per component --m3e-comp-<component>-*.
Override any of them in your own CSS; read them in yours instead of hard-coding values:
.headline {
color: var(--m3e-sys-color-on-surface);
font-family: var(--m3e-sys-typescale-headline-medium-font);
font-size: var(--m3e-sys-typescale-headline-medium-size);
line-height: var(--m3e-sys-typescale-headline-medium-line-height);
}