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:
-
Default:
:rootand:hostsetcolor-scheme: light dark, solight-dark()follows the OS. -
:root[theme='auto']is the same follow-OS value, written explicitly. -
: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.
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.
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.