Theme Toggle

A button that switches between light and dark themes and remembers the choice, plus theme-init, which applies the saved choice before the first paint. Without a saved choice, the operating system setting applies.

Source on GitHub

Usage

Put <theme-init /> in the <head> of every page, usually through a shared head component, and <theme-toggle /> wherever the button belongs. Both need the color-scheme rules from a token stylesheet; see Theming.

examples/components/theme-toggle/theme-toggle.html
html
<theme-toggle />
html examples/components/theme-toggle/theme-toggle.html
<!-- @bascik
A button that switches between light and dark themes and remembers the choice. Needs <theme-init /> in <head> and the color-scheme rules in styles.css. Hidden until its script runs.
-->
<style>
  .toggle {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2, 0.5rem);
    padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
    border: 1px solid var(--color-border, currentColor);
    border-radius: var(--radius, 0.5rem);
    background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
    color: inherit;
    font: inherit;
    cursor: pointer;
  }
</style>
<button class="toggle" id="toggle" type="button" hidden>Dark theme</button>
<script>
  // aria-pressed tells assistive technology whether dark theme is on; the label stays the same.
  const toggle = document.getElementById('toggle');
  const media = matchMedia('(prefers-color-scheme: dark)');
  const preview = toggle.closest('[data-demo-theme]');
  const themeTarget = preview ?? document.documentElement;
  const themeAttribute = preview ? 'data-demo-theme' : 'data-theme';
  const storageKey = preview ? 'theme-demo' : 'theme';

  if (preview) {
    try {
      const savedTheme = localStorage.getItem(storageKey);
      if (savedTheme === 'light' || savedTheme === 'dark') preview.setAttribute(themeAttribute, savedTheme);
    } catch {
      // The preview keeps its default theme when storage is unavailable.
    }
  }

  const current = () => themeTarget.getAttribute(themeAttribute) ?? (media.matches ? 'dark' : 'light');

  const render = () => {
    toggle.setAttribute('aria-pressed', String(current() === 'dark'));
  };

  toggle.addEventListener('click', () => {
    const next = current() === 'dark' ? 'light' : 'dark';
    themeTarget.setAttribute(themeAttribute, next);
    try {
      localStorage.setItem(storageKey, next);
    } catch {
      // Not persisted when storage is unavailable; the choice still applies here.
    }
    render();
    // Other components can react without reaching into this one.
    toggle.dispatchEvent(new CustomEvent('theme-change', { bubbles: true, detail: { theme: next } }));
  });

  media.addEventListener('change', render);
  // Stay in sync when another toggle, or any other script, changes the theme.
  new MutationObserver(render).observe(themeTarget, { attributes: true, attributeFilter: [themeAttribute] });
  render();
  toggle.hidden = false;
</script>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.

The button above switches only the preview theme. It stores the choice under theme-demo, separate from the docs site's theme setting.

theme-init

html examples/components/theme-init/theme-init.html
<!-- @bascik
Applies the saved light or dark theme before first paint. Place it in <head> of every page so there is no flash of the wrong theme; its script stays in <head> and runs before the body renders. Pairs with <theme-toggle />.
-->
<!-- Tells the browser both schemes are supported before any CSS loads, so form controls and the canvas match. -->
<meta name="color-scheme" content="light dark" />
<script>
  try {
    const theme = localStorage.getItem('theme');
    if (theme === 'light' || theme === 'dark') document.documentElement.setAttribute('data-theme', theme);
  } catch {
    // Storage can be unavailable (private mode, blocked cookies). The operating system theme then applies.
  }
</script>

How It Works

  • theme-init runs synchronously in <head>, before the body renders, and sets data-theme on <html> from localStorage. Bascik keeps a component's script in <head> when the component is used there, so there is no flash of the wrong theme.
  • On a normal site, the token stylesheet reads data-theme from <html>, so switching the attribute recolors the whole page. In this preview, the toggle instead changes the preview's data-demo-theme and stores the choice under theme-demo.
  • The button exposes its state with aria-pressed and is hidden until its script runs.
  • After each change it dispatches a bubbling theme-change event, so other components can react without reaching into this one.

Adapt It

  • For a three-way system, light, and dark choice, use a <select> and remove the stored value for "system". See Theming.