Skip to content

Theme toggle

2 min read · Updated 8 Oct 2026

The theme toggle is a header control that sets data-theme on <html>. Colour tokens, resolving the system preference and restyling mounted objects are covered in Theming; this page covers the control and keeping the choice.

Theme toggle: Light, dark and system are explicit choices with a visible selected state.

Illustrative example with synthetic data. Light, dark and system are explicit choices with a visible selected state.

Use one when the page has a dark theme.

  1. Should the page be able to follow the operating system again after the reader picks a theme? Use the three-way choice: light, dark and system.
  2. Otherwise, use the toggle button.
Variant Contents Common use cases Choose another variant when
Toggle button One button, pressed when dark An overview with few header controls Readers expect the page to follow a system theme that changes by time of day
Three-way choice Three radios: light, dark, system A page kept open all day, or opened on shared machines The header has no room; put the choice in a settings menu

Set the theme before the first paint with an inline classic script in <head>, before the stylesheets. A module script runs after the first paint, so the page would flash in the light theme. If your host’s policy blocks inline scripts, load the same lines as a separate classic script without defer or async.

<head>
<script>
(() => {
let choice = null;
try {
choice = localStorage.getItem('theme');
} catch {}
const dark = choice === 'dark' || (choice !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
})();
</script>
</head>

Toggle button:

<button class="theme-toggle" type="button" aria-pressed="false">
<svg class="icon-moon" aria-hidden="true"><!-- moon --></svg>
<svg class="icon-sun" aria-hidden="true"><!-- sun --></svg>
<span class="control__label">Dark theme</span>
</button>

Three-way choice:

<fieldset class="theme-choice">
<legend class="visually-hidden">Theme</legend>
<label>
<input type="radio" name="theme" value="light" />
<svg aria-hidden="true"><!-- sun --></svg>
<span class="visually-hidden">Light</span>
</label>
<label>
<input type="radio" name="theme" value="dark" />
<svg aria-hidden="true"><!-- moon --></svg>
<span class="visually-hidden">Dark</span>
</label>
<label>
<input type="radio" name="theme" value="system" />
<svg aria-hidden="true"><!-- monitor --></svg>
<span class="visually-hidden">System</span>
</label>
</fieldset>
  • The button’s label stays “Dark theme”; aria-pressed carries the state.
  • .control__label and .visually-hidden are the header’s hiding rules.
:root {
/* Native controls, scrollbars and form fields follow the theme. */
color-scheme: light;
}
:root[data-theme='dark'] {
color-scheme: dark;
}
/* The icon shows the theme a press switches to. */
.theme-toggle .icon-sun {
display: none;
}
.theme-toggle[aria-pressed='true'] .icon-sun {
display: block;
}
.theme-toggle[aria-pressed='true'] .icon-moon {
display: none;
}
/* 1 + 3 × 34 + 2 × 1 + 1 = 106px wide, 36px tall. */
.theme-choice {
display: flex;
box-sizing: border-box;
height: var(--header-control);
min-inline-size: 0;
margin: 0;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.theme-choice > label {
position: relative;
display: grid;
place-items: center;
width: calc(var(--header-control) - 2px);
cursor: pointer;
}
.theme-choice > label + label {
border-inline-start: 1px solid var(--line);
}
/* The transparent radio covers its label, so it keeps focus, arrow keys and clicks. */
.theme-choice input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: inherit;
}
.theme-choice > label:has(:checked) {
background: color-mix(in srgb, var(--ink) 10%, transparent);
}
.theme-choice > label:has(:focus-visible) {
outline: 2px solid var(--ink);
outline-offset: -3px;
}

When the header keeps dark chrome in both themes, use the chrome tokens; see Keep the chrome consistent across themes.

type Choice = 'light' | 'dark' | 'system';
type Theme = 'light' | 'dark';
// From /app-theming/#define-colour-tokens.
declare function applyTheme(choice: Choice): Theme;
// Re-applies every colour operation; see /app-theming/#switch-themes-in-place.
declare function restyleObjects(theme: Theme): Promise<void>;
const KEY = 'theme';
const media = matchMedia('(prefers-color-scheme: dark)');
const toggle = document.querySelector<HTMLButtonElement>('.theme-toggle');
const radios = [...document.querySelectorAll<HTMLInputElement>('.theme-choice input')];
const readChoice = (): Choice => {
try {
const stored = localStorage.getItem(KEY);
return stored === 'light' || stored === 'dark' ? stored : 'system';
} catch {
return 'system';
}
};
const saveChoice = (choice: Choice) => {
try {
if (choice === 'system') localStorage.removeItem(KEY);
else localStorage.setItem(KEY, choice);
} catch {
// Storage is blocked: the choice lasts until the page closes.
}
};
let choice = readChoice();
// Restyles run in order, so a quick second switch cannot finish before the first.
let restyles = Promise.resolve();
const apply = () => {
const before = document.documentElement.dataset.theme;
const theme = applyTheme(choice);
if (theme !== before) {
restyles = restyles
.then(() => restyleObjects(theme))
.catch((error: unknown) => console.error('Restyle failed', theme, error));
}
toggle?.setAttribute('aria-pressed', String(theme === 'dark'));
for (const radio of radios) radio.checked = radio.value === choice;
};
const choose = (next: Choice) => {
choice = next;
saveChoice(next);
apply();
};
toggle?.addEventListener('click', () => {
choose(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
});
for (const radio of radios) {
radio.addEventListener('change', () => {
if (radio.value === 'light' || radio.value === 'dark' || radio.value === 'system') choose(radio.value);
});
}
media.addEventListener('change', () => {
if (choice === 'system') apply();
});
apply();
  • Keep the storage key and values the same in the pre-paint script and the bundle. Choosing system removes the key.
  • The pre-paint script has already set the theme, so the first apply() restyles nothing. Build each object’s operations for the current theme when you mount it.
  • The toggle button stores an explicit theme on its first press, so the page stops following the system; the three-way choice can return to it.
  • Anything painted from CSS tokens, including SVG with fill: var(--…), follows data-theme without code. Redraw canvas you paint yourself.
  • A theme switch costs no query.
Width Toggle button Three-way choice
Wide header Icon and label Three icons, 106px
Narrow header Icon only; the label stays as the accessible name Unchanged

The header’s fold width is set in Header.