Skip to content

ExpressiveCSS

Browse documentation

Themes

Light and dark schemes, the theme attribute, and custom tokens.

View as Markdown

Expressive maps Material Design 3 color tokens onto live --md-sys-color-* custom properties. Components, buttons, and the color utilities all read those live names, so flipping the scheme recolors the page without extra classes.

Three rules decide which pair is live, in this order:

  1. Default: :root and :host set color-scheme: light dark, so light-dark() follows the OS.
  2. :root[theme='auto'] is the same follow-OS value, written explicitly.
  3. :root[theme='light'] and :root[theme='dark'] lock the scheme and override the OS.

The attribute also sets color-scheme so native controls follow the same scheme. Each of those three rules has a :host([theme='…']) twin, so you can pin a shadow host to a theme the same way as <html>. The sheet is supported as a shadow root's only stylesheet, and :root matches the document element and nothing else, so every token the sheet declares has that pairing.

This site starts as <html lang="en" theme="auto">. The theme control in the app bar is a menu: Light, Dark, and Auto. The site writes the choice to localStorage and restores it before first paint so a reload does not flash the wrong scheme.

These tokens follow the page theme

Change the theme from the app bar menu or the buttons in Changing theme, and this card, the buttons, and the swatches change with it.

.primary
.primary-container
.surface-container-highest
.secondary
.tertiary
.error
Filled Tonal Outlined

Reading the theme

To restore a saved choice without a flash of the wrong scheme, put a short inline script in <head>, before the stylesheet paints. The framework does not run this script. The theme switch below saves a choice, but restoring it before first paint is the page's job.

Without a theme attribute, or with theme="auto", the CSS already follows the OS. theme="light" and theme="dark" lock it. The snippet below restores a stored choice before first paint.

<script>
  function getTheme() {
    const stored = localStorage.getItem('theme');
    if (stored === 'dark' || stored === 'light' || stored === 'auto') return stored;
    return 'auto';
  }

  function setTheme(theme) {
    document.documentElement.setAttribute('theme', theme);
    localStorage.setItem('theme', theme);
  }

  setTheme(getTheme());
</script>

Keep this inline rather than in an external file so it runs before the first paint. This docs site does that in <head>.

Changing the theme

Switch schemes by writing the attribute:

document.documentElement.setAttribute('theme', 'dark');

Or let the Theme behavior do it. A click on any element with data-theme-set="light", "dark" or "auto" writes the attribute and saves the choice to localStorage under theme, the key the snippet above reads. The root bundle starts it on load. With the modular entry, call Theme.Init(). It marks each theme control as current or not, with aria-checked on menuitemradio and radio roles and aria-pressed on anything else.

<div class="button-group" role="group" aria-label="Theme">
  <button type="button" class="button" data-theme-set="light">Light</button>
  <button type="button" class="button" data-theme-set="dark">Dark</button>
  <button type="button" class="button" data-theme-set="auto">Auto</button>
</div>

From script, Expressive.Theme.set('dark') does the same as a click, Expressive.Theme.get() returns the current theme, and Expressive.Theme.sync(root) marks theme controls rendered after load.

This site's app-bar menu predates the behavior and also swaps its icon, so it writes the attribute itself:

Expressive.Menu.init(document.getElementById('theme-toggle'), {
  alignment: 'right',
  constrainWidth: false,
  onItemClick: (li) => setTheme(li.dataset.theme)
});

The same three choices live in the top-right of every docs page. A reload keeps the one you picked.

Creating a theme

Customize by overriding the Material Design 3 tokens. Do not set --background-color, --primary-color, or other pre-2.1 names. Expressive does not read them.

Three layers exist. tokens/_reference.scss generates the tonal ramps from --md-source, then resolves them into the pairs (--md-sys-color-primary-light, --md-sys-color-primary-dark, and the rest). tokens/_theme.scss collapses each pair into the live --md-sys-color-* name with light-dark(). Components only read the live names.

Override the -light and -dark pairs so both schemes stay consistent. Load your sheet after Expressive so equal-specificity :root rules win.

:root {
  --md-sys-color-primary-light: #6750a4;
  --md-sys-color-on-primary-light: #ffffff;
  --md-sys-color-primary-container-light: #eaddff;
  --md-sys-color-on-primary-container-light: #21005d;

  --md-sys-color-primary-dark: #d0bcff;
  --md-sys-color-on-primary-dark: #381e72;
  --md-sys-color-primary-container-dark: #4f378b;
  --md-sys-color-on-primary-container-dark: #eaddff;
}

To restyle only one scheme, set the live names on the attribute selector. A bare :root { --md-sys-color-primary: … } loses to :root[theme='dark'] because the attribute rule is more specific.

:root[theme='dark'] {
  --md-sys-color-primary: #d0bcff;
  --md-sys-color-on-primary: #381e72;
  --md-sys-color-surface: #1c1b1f;
  --md-sys-color-on-surface: #e6e1e5;
}

The same custom properties inherit, so a wrapper can preview a palette without touching the rest of the page:

Scoped preview

This panel overrides primary on itself. The filled and tonal buttons pick up the new tokens; the rest of the page does not.

Filled Tonal Outlined
<article style="
  --md-sys-color-primary: #6750a4;
  --md-sys-color-on-primary: #ffffff;
  --md-sys-color-primary-container: #eaddff;
  --md-sys-color-on-primary-container: #21005d;
">
  <a class="button filled">Filled</a>
</article>

In Sass, read the live token. The values are hex, so rgba(var(--md-sys-color-primary), 0.06) is invalid and the browser drops it. Mix with transparency instead:

.my-surface {
  background-color: var(--md-sys-color-surface);
  color: var(--md-sys-color-on-surface);
}

.my-overlay {
  background-color: color-mix(in oklab, var(--md-sys-color-primary) 6%, transparent);
}

Set --md-source to recolor every generated ramp, and every system color that resolves to one, at runtime without a rebuild. It is the live seed for the generated ramps. Expressive ships #c6a0f6, Mist Iris. The palette icon in the navbar opens a picker that writes this property. The picker stores the choice in localStorage so you can browse the rest of the docs in the palette you picked.

:root {
  --md-source: #6750a4;   /* one line; the whole theme follows */
}

Expressive derives five of the six ramps this way with oklch(from var(--md-source) …). The error ramp is not derived. Material fixes the error hue because "this went wrong" should not change color when you change your brand color.

Overriding an individual -light/-dark pair still works and still wins over the generated value. Use a pair override to change one role and --md-source to change the whole palette.

The table shows where the shipped seed #c6a0f6 lands for the roles you will most often replace. These are resolved values. The stylesheet ships the oklch() expression and the browser computes the color, including gamut-mapping the entries whose target chroma sRGB cannot reach. Treat them as accurate to about a rounding step, not as literals to paste back in.

Token Light Dark
primary#6e4995#d8b2ff
on-primary#ffffff#3f1861
primary-container#f5ceff#56307a
on-primary-container#2a0048#f5ceff
secondary#635a6b#ccc1d5
secondary-container#e8ddf2#4b4253
tertiary#7a5059#e8b7c0
error#ba1a1a#ffb4ab
surface#faf8fb#090809
on-surface#1c1b1d#e3e1e4
outline#7a757c#938e96

error is the one row that does not move when you change the seed, because its ramp is not generated.

The full list of role utilities lives on the Color page. Pair a fill with its on-* text class so contrast stays correct when the scheme flips.

Vibrant emphasis

Vibrant is Material 3 Expressive's emphasis axis. It draws the same component on an accent container instead of a neutral surface, so it is not a separate component variant. Material ships it as a parallel token set per component, such as menus-vibrant and toolbar-vibrant, so every component that gains the axis restates the same mappings.

Expressive makes it a foundation instead. Add the vibrant attribute to remap the surface family of --md-sys-color-* roles inside its subtree. Every component already reads those roles, so components need no per-component styles. Nothing changes until you write the attribute.

<article vibrant>
  <p class="title-medium">Vibrant</p>
  <a class="button filled">Filled</a>
</article>

Vibrant surface

This panel is an ordinary Card, an <article> with no vibrant styling of its own anywhere in the sheet. It carries the attribute, so the surface and text roles it already read resolve to the tertiary container instead. The buttons still read primary and keep standing out against it, and outlines stay neutral. Flip the theme and it follows.

Filled Tonal Outlined

The attribute uses one ramp, tertiary, which is what Material uses for the axis. Pointing the surfaces at primary or secondary would hide every component whose own fill is that container, such as a tonal button, a FAB, or .toolbar.vibrant itself. For a different ramp, set the surface tokens on your own wrapper the way Creating a theme shows for any token.

Inside a vibrant subtree, every surface-container level resolves to the one container color, because that is what a vibrant surface is in Material. You cannot tell nested surfaces apart there. Put the attribute on the component you want emphasized, not on the page. The remap points at the live role names rather than a -light/-dark pair, so a runtime theme switch still reaches it.

Menus and toolbars also have a .vibrant class. Those classes are Material's own per-component token sets, with the colors that spec gives each one. The attribute is the general axis and works on any element. A menu inside a vibrant subtree adopts its own vibrant mapping automatically. A selected menu item fills with tertiary-container, the same color the attribute gives the menu, so the selection would not show against the surface. Material handles that by moving selection to solid tertiary, which the class already did.

The attribute works in a shadow root too. A sheet loaded there cannot reach its own host with a descendant selector, so it matches :host([vibrant]) alongside it. The token mapping carries :host for the same reason.

More contrast

Material 3 has three contrast levels. Put contrast="medium" or contrast="high" on the root or on any subtree to use the medium or high scheme there. The accent roles, their containers, on-surface-variant and the outlines move to darker tones in the light scheme and lighter ones in the dark scheme. Those tones come from the same ramps, so a runtime --md-source change still reaches them. Surfaces stay put.

Medium brings accents to about 4.5:1 against the surface and high to 7:1, with outlines at 3:1 or more. When the user asks for more contrast (prefers-contrast: more), the page uses the high scheme unless a contrast attribute says otherwise. Set contrast="standard" to opt a page or subtree out.

The tones approximate Material's contrast curves rather than reproducing its color solver. A page that re-themes through the -light and -dark pairs should repeat those overrides inside its own [contrast] rule.

<html contrast="high">

No matching pages

  • Source color

    The seed every generated ramp derives from. Pick one and browse the docs — the whole theme follows. Error does not: it is a fixed hue.

    Default source: Mist Iris.

    ExpressiveCSS presets: Lagoon, Harbor, Breeze, Cobalt, Iris.

    Bold

    Soft

    Mist

    Glow