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 current = () => document.documentElement.getAttribute('data-theme') ?? (media.matches ? 'dark' : 'light');

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

  toggle.addEventListener('click', () => {
    const next = current() === 'dark' ? 'light' : 'dark';
    document.documentElement.setAttribute('data-theme', next);
    try {
      localStorage.setItem('theme', next);
    } catch {
      // Not persisted when storage is unavailable; the choice still applies to this page.
    }
    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(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
  render();
  toggle.hidden = false;
</script>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.

The button above switches this page's theme. It stores the choice in localStorage, which this docs site does not read on the next visit.

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.
  • The token stylesheet writes every color as light-dark(light, dark) and sets color-scheme from data-theme, so switching the attribute recolors the whole page with no other rules.
  • 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.