Themes

Themes

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

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 is paired with a :host([theme='…']) twin, so a shadow host can be pinned to a theme the same way <html> can. That pairing is the rule for every token the sheet declares — the sheet is supported as a shadow root's only stylesheet, and :root matches the document element and nothing else.

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

These tokens follow the page theme

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

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

Reading the theme

Put a short inline script in <head>, before the stylesheet paints, if you want to restore a saved choice and avoid a flash of the wrong scheme. The framework itself does not run this — you own persistence.

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. That is the entire public API — there is no Expressive.theme helper.

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

Bind that to a control. The app-bar menu on this site writes one of those three values and persists it with setTheme from the snippet above:

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. 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 — those are not wired in Expressive.

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, consume 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);
}

--md-source is the seed the ramps are generated from, and it is live: set it and every generated ramp — and every system color that resolves to one — recolors at runtime, with no rebuild. Expressive ships #006A79. The palette icon in the navbar opens a picker that writes this property; the choice is stored 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 */
}

Five of the six ramps are derived this way with oklch(from var(--md-source) …). The error ramp is deliberately not: Material fixes the error hue rather than deriving it, 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 — that is the more surgical tool. --md-source is the blunt one.

Where the shipped seed #006A79 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 — so treat them as accurate to about a rounding step rather than as literals to paste back in.

Token Light Dark
primary#006a7a#4fdbf5
on-primary#ffffff#003740
primary-container#aaf0ff#00505c
on-primary-container#002026#aaf0ff
secondary#4b6369#b1cbd3
secondary-container#cde8ef#344b51
tertiary#535c7f#bac4ed
error#ba1a1a#ffb4ab
background#fafdff#1a1c1d
surface#fafdff#1a1c1d
on-surface#1a1c1d#e0e3e4
surface-variant#d9e5e8#3f484b
outline#6f797c#889296

error is the one row that does not move when you change the seed — it is the ramp that 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 is not a component variant: it is the same component drawn on an accent container instead of a neutral surface. Material ships it as a parallel token set per component — menus-vibrant, toolbar-vibrant — so every component that gains the axis restates the same mappings.

Expressive makes it a foundation instead. The vibrant attribute remaps the surface family of --md-sys-color-* roles inside its subtree, and every component already reads those roles, so nothing per-component is needed and 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

There is one ramp, and it is tertiary — what Material uses for the axis. Pointing the surfaces at primary or secondary instead would erase every component whose own fill is that container: a tonal button, a FAB, .toolbar.vibrant itself. If you want a different one, set the surface tokens on your own wrapper the way Creating a theme shows for any token.

Two things to know. The elevation ladder collapses — every surface-container rung resolves to the one container color, because that is what a vibrant surface is in Material, so nesting surfaces inside a vibrant subtree stops telling them apart. Put the attribute on the component you want emphasized, not on the page. And the remap points at the live role names rather than a -light/-dark pair, which is why a runtime theme switch still reaches it.

Menus and toolbars also carry a .vibrant class. Those 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 anything. A menu inside a vibrant subtree adopts its own vibrant mapping automatically, because it has to: a selected menu item is filled with tertiary-container, which is the very color the attribute paints the menu with, so selection would vanish into its own surface. Material answers that by moving selection to solid tertiary, which is what 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 :host([vibrant]) is matched alongside it — the same reason the token mapping carries :host.

  • 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.