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.