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