Skip to content

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.

createTheme options
OptionValuesWhat 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);
}