Theme controls
A palette button and theme controls that generate Material 3 color roles from one source color, with a reusable HCT color picker and a saved preference.
import 'material-next-web-components/theme.js';On this page
Overview
The palette button in this site's top app bar is <mnw-theme-button>. It opens theme controls like those on material-web.dev: pick a source color with the swatch or the hue, chroma and tone sliders, and choose dark, system or light. The page's color roles are generated from that color with Material Color Utilities, the library behind Android's dynamic color, and the choice is saved for the next visit.
Four elements, from the whole to its parts:
<mnw-theme-button>: a top app bar button that opens<mnw-theme-controls>in a panel, themes the document and remembers the choice.<mnw-theme-controls>: the source color, the color mode and a reset to the page's own colors. Controlled by default; it can persist and apply on its own.<mnw-color-picker>: the reusable color picker, a hex swatch and three HCT sliders. Use it anywhere a color is chosen, for example an app's seed color.<mnw-hct-slider>: one hue, chroma or tone slider over the gradient it reaches.
Live demo
<div class="demo-theme">
<mnw-theme-controls id="demo-theme-controls" heading="Theme"></mnw-theme-controls>
<div class="demo-theme__preview" aria-label="Generated roles">
<div class="demo-theme__swatches"></div>
</div>
</div>
<div class="demo-theme__picker">
<mnw-color-picker id="demo-color-picker" value="#006a6a" swatch-label="Seed color"></mnw-color-picker>
</div>
<pre class="demo-log" aria-live="polite"></pre>import {
DEFAULT_SOURCE_COLOR,
colorScheme,
type ColorRole,
type MaterialNextColorPicker,
type MaterialNextThemeControls,
} from '../../theme/index.js';
const ROLES: ColorRole[] = [
'primary',
'primary-container',
'secondary',
'secondary-container',
'tertiary',
'tertiary-container',
'surface-container',
'surface-container-highest',
];
export default (root: HTMLElement) => {
const controls = root.querySelector<MaterialNextThemeControls>(
'#demo-theme-controls'
)!;
const picker =
root.querySelector<MaterialNextColorPicker>('#demo-color-picker')!;
const swatches = root.querySelector<HTMLElement>('.demo-theme__swatches')!;
const log = root.querySelector<HTMLElement>('.demo-log')!;
// Controlled: this demo applies nothing to the page and stores nothing;
// it draws the roles the choice generates.
const draw = () => {
const {sourceColor = DEFAULT_SOURCE_COLOR, mode} = controls.preference;
const dark =
mode === 'dark' ||
(mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
const scheme = colorScheme(sourceColor, {dark});
swatches.replaceChildren(
...ROLES.map((role) => {
const swatch = document.createElement('span');
swatch.className = 'demo-theme__swatch';
swatch.style.background = scheme[role];
swatch.style.color =
scheme[`on-${role}` as ColorRole] ?? scheme['on-surface'];
swatch.textContent = role;
return swatch;
})
);
};
controls.addEventListener('color-theme-change', (e) => {
log.textContent = `color-theme-change ${JSON.stringify(e.detail)}`;
draw();
});
picker.addEventListener('change', () => {
log.textContent = `color picker change ${picker.value}`;
});
draw();
};Usage
One element in the app bar themes the page and remembers the choice:
<script type="module">
import 'material-next-web-components/theme.js';
</script>
<mnw-theme-button storage-key="my-app:theme"></mnw-theme-button>Every page on the origin with the same storage-key shares the theme. default-color is the seed shown until a color is chosen; set it to your page's own seed.
Applying a theme
applyColorTheme({sourceColor, mode}) writes the roles as --md-sys-color-* custom properties in one <style> at the end of <head> and sets data-color-mode (light, dark or system) on the root element. Without sourceColor the style is removed and the page's own colors show. Pages with their own dark tokens can follow the mode with:
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode='light']) {
/* dark tokens */
}
}
:root[data-color-mode='dark'] {
/* dark tokens */
}The generated style uses :root[data-color-mode], as specific as those rules and later in the document, so a chosen color wins over the page's tokens in both modes.
Controlled controls
Without storage-key, store or apply, <mnw-theme-controls> only reports. Apply each color-theme-change yourself:
import {applyColorTheme} from 'material-next-web-components/theme.js';
controls.addEventListener('color-theme-change', (event) => {
applyColorTheme(event.detail); // {sourceColor?, mode}
});The preference getter returns the current choice, and reset() returns to the page's own colors, keeping the mode.
The color picker on its own
<mnw-color-picker value="#006a6a" swatch-label="Seed color"></mnw-color-picker>value is #rrggbb. It fires input while the color changes and change when a slider is let go or the system dialog closes. The sliders keep their own HCT values, so dragging chroma to 0 keeps the chosen hue. Chroma beyond what sRGB can show is clipped in value.
Color schemes without the elements
The domain functions are pure, for build scripts, server rendering and tests:
import {
colorScheme,
colorThemeCss,
} from 'material-next-web-components/theme.js';
colorScheme('#006a6a', {dark: true, variant: 'content'}).primary; // '#…'
colorThemeCss({sourceColor: '#006a6a', mode: 'system'}); // CSS for :rootvariant is tonal-spot (Android's default), content (keeps the source color as primary) or vibrant. contrast runs from -1 to 1.
Storage
createThemePreferenceStore({key}) keeps {"version": 1, "sourceColor": "#…", "mode": "dark"} under key (default mnw-theme). Invalid fields are dropped by normalizeThemePreference(), and storage that is full, blocked or corrupt falls back to memory. Choosing system mode with no color forgets the entry. Another tab's change updates the open controls.
API
<mnw-theme-button>
A palette button for a top app bar. It opens <mnw-theme-controls> in a
panel under it, themes the document and remembers the choice, so a page
needs only this element.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
open | open | boolean | false | Whether the panel is open. |
label | label | string | 'Theme' | |
storageKey | storage-key | DEFAULT_THEME_STORAGE_KEY | localStorage key; every page on the origin with the same key shares the theme. | |
store | Property only | ThemePreferenceStore | undefined | A store of your own, instead of storage-key. | |
defaultColor | default-color | DEFAULT_SOURCE_COLOR | The page's own seed, shown until a color is chosen. |
| Event | Description |
|---|---|
color-theme-change | From the controls: {sourceColor?, mode}. |
<mnw-theme-controls>
A page's theme controls: a source color (hex swatch and HCT sliders), the color mode, and a reset to the page's own colors.
Controlled by default: set source-color and mode, and apply each
color-theme-change yourself. Set storage-key (or store) to persist the
choice, and apply to theme the document as well.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
sourceColor | source-color | string | '' | The chosen source color; empty keeps the page's own colors. |
mode | mode | ColorMode | 'system' | Light, dark or the system's choice. |
defaultColor | default-color | DEFAULT_SOURCE_COLOR | Shown in the picker until a color is chosen: the page's own seed. | |
heading | heading | string | 'Theme' | |
storageKey | storage-key | string | '' | Persist under this localStorage key; empty persists nothing. |
store | Property only | ThemePreferenceStore | undefined | A store of your own, instead of storage-key. | |
apply | apply | boolean | false | Theme this element's document on every change. |
| Event | Description |
|---|---|
color-theme-change | {sourceColor?, mode} after each change; no sourceColor means the page's own colors. |
<mnw-color-picker>
A color picker in Material's HCT space: a hex swatch that opens the system color dialog, and hue, chroma and tone sliders. Reusable on its own, for example to choose an app's seed color.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
value | value | DEFAULT_SOURCE_COLOR | The color, as #rrggbb. | |
swatchLabel | swatch-label | string | 'Source color' | Label of the swatch row. |
| Event | Description |
|---|---|
type | |
input | While the color changes; read value. |
change | When a change is committed (a slider let go, the dialog closed). |
| Part | Description |
|---|---|
swatch | The round swatch. |
sliders | The surface around the three sliders. |
<mnw-hct-slider>
One HCT channel (hue, chroma or tone) as a labelled Material slider over a
gradient of the colors it reaches from color.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
channel | channel | HctChannel | 'hue' | Which channel the slider sets. |
label | label | string | '' | Visible and accessible label. |
value | value | number | 0 | The channel's value: hue 0 to 360, chroma 0 to 150, tone 0 to 100. |
color | color | string | '' | The current color, as hex; the gradient varies only this channel. |
| Event | Description |
|---|---|
input | While the value changes. |
change | When the visitor lets go. |
Methods: show(), close() and toggle() on the button, reset() on the controls.
Module exports
| Layer | Exports |
|---|---|
| Domain | colorScheme, colorThemeCss, hctFromHex, hexFromHct, normalizeHex, isColorMode, isColorVariant, COLOR_MODES, COLOR_VARIANTS, COLOR_ROLES, HCT_RANGES, DEFAULT_SOURCE_COLOR, types ColorMode, ColorVariant, ColorRole, ColorTheme, ColorSchemeOptions, HctColor, HctChannel |
| Data | createThemePreferenceStore, normalizeThemePreference, DEFAULT_THEME_STORAGE_KEY, types ThemePreference, ThemePreferenceStore |
| UI | applyColorTheme, MaterialNextThemeButton (<mnw-theme-button>), MaterialNextThemeControls (<mnw-theme-controls>), MaterialNextColorPicker (<mnw-color-picker>), MaterialNextHctSlider (<mnw-hct-slider>), event type ColorThemeChangeEvent |
Accessibility
- The palette button reports
aria-expanded; the panel is a labelled dialog. Escape closes it and returns focus to the button; a click outside closes it too. - Each slider has a visible label, its accessible name, and shows its value while dragged. The gradients are decorative.
- The swatch is the native color input, so the system color dialog and its keyboard support come with it; it shows a focus ring.
- The color mode buttons are a labelled segmented button set, each with a name ("Dark color mode").
- In forced colors, the surfaces get a border and the swatch and gradients keep their real colors.
Limitations
- Colors apply when the script runs; the saved mode is applied before first paint only when the page adds a small inline script, as this site does.
applyColorTheme()themes--md-sys-color-*roles. Tokens of your own (a code background, a success color) keep their values.