<theme-switcher>

A multi-state theme picker built on <form-multi-switch>. See the README for installation and the API.

Note: All controls on this page share its theme and use the demo-only storage key theme-switcher-demo, except the temporary control, which doesn’t save changes. Framed examples have their own page themes and storage settings.

Use Tab to reach a group, arrow keys to move between choices, and Space to select. Only System follows changes to your device’s color preference.

The basics

Configure the control with attributes

The component sets data-theme on <html>; the page’s CSS supplies the colors. All markup is generated by the component.

<theme-switcher></theme-switcher>

/* Your page styles */
:root { color-scheme: light dark; }
:root[data-theme='light'] {
  color-scheme: light;
}
:root[data-theme='dark'] {
  color-scheme: dark;
}
body {
  background: Canvas;
  color: CanvasText;
}
Waiting for the component.

Reload to try restoring the saved choice.

Make it yours

Example: Restyle the segments

Use the base component’s --multi-switch-* properties to override the component’s default design.

<theme-switcher class="pill"
  label="Site appearance">
</theme-switcher>

.pill {
  --multi-switch-radius: 999px;
  --multi-switch-border: #173f63;
  --multi-switch-background: #eef4ff;
  --multi-switch-color: #173f63;
  --multi-switch-selected-background: #173f63;
  --multi-switch-selected-color: #fff;
}

Add icons, with or without visible labels

This example uses CSS masks to add decorative icons. In icon-only mode, it hides the labels visually, but they remain available to assistive technology. By default the <legend> element is displayed, but you can hide that using your own CSS.

<theme-switcher class="icons"
  label="Icons and text">
</theme-switcher>
<theme-switcher class="icons hide-legend" icon-only
  label="Icon-only appearance">
</theme-switcher>

.icons [data-multi-switch-choice]::before {
  content: '';
  inline-size: 1.25em;
  block-size: 1.25em;
  background: currentColor;
  mask: var(--theme-icon) center / contain
    no-repeat;
}
.icons [data-value='dark'] {
  --theme-icon: url('./dark.svg');
}
/* Continues with system.svg and light.svg too. */

theme-switcher.hide-legend legend {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

Translate the labels

Default labels are English, but you can translate everything. Supply translated (or custom) legend and choice labels via their attributes.

<theme-switcher lang="fr"
  label="Thème"
  system-label="Appareil"
  light-label="Clair"
  dark-label="Sombre">
</theme-switcher>

<div lang="ar" dir="rtl">
  <theme-switcher class="icons"
    label="مظهر الموقع"
    system-label="النظام"
    light-label="فاتح"
    dark-label="داكن">
  </theme-switcher>
</div>

Want more themes?

You can add to the number of themes by replacing the choices. Be sure to include System, Light, and Dark alongside your custom options, then use your own CSS to implement the extra palettes.

<theme-switcher storage="none"
  label="Reading palette"
  choices='[
    {"label":"System","value":"system"},
    {"label":"Light","value":"light"},
    {"label":"Dark","value":"dark"},
    {"label":"Sepia","value":"sepia"}
  ]'>
</theme-switcher>

:root[data-theme='sepia'] {
  color-scheme: light;
  --page-background: #f6ecd6;
  --page-color: #493921;
}

Storing preferences

By default, preferences are saved to `localStorage`, but you can control that and back it with cookies for server-side persistence.

Cookie example

Add the cookie attribute to save the user’s preference in both `localStorage` and a cookie, so your server can read it too. On startup, a valid `localStorage` value wins, with the cookie as a fallback. A bare attribute uses the storage key as the cookie name; supply a value to name it separately. storage="none" disables both stores.

<theme-switcher
  storage-key="theme-switcher-cookie-demo"
  cookie
  label="Saved site appearance">
</theme-switcher>

<!-- Or choose a different cookie name: -->
<theme-switcher
  storage-key="theme-switcher-cookie-demo"
  cookie="server-theme">
</theme-switcher>

Plan for loading and failure

Keep a native fallback

If you want to enable a server-side fallback, you can do that by authoring a native fallback using a radio control group in your markup, wrapping it in this component. If the component fails to boot, the radios remain usable, and the form still has a value to submit.

Disable JavaScript and reload this page to compare authored controls with generated ones.

<theme-switcher>
  <fieldset>
    <legend>Color theme</legend>
    <label><input type="radio"
      name="theme" value="system"
      checked>System</label>
    <!-- Light and Dark radios too -->
  </fieldset>
</theme-switcher>

/* No loading-state CSS hides the inputs.
   No module means no page-theme behavior. */

Handle unavailable storage

A blocked storage API doesn’t stop the switcher from working. This isolated example simulates that restriction and displays the diagnostic information.

control.addEventListener(
  'theme-switcher:error', ({ detail }) => {
    console.error(detail.phase, detail.error);
  });

// A storage error still allows:
await control.ready;
control.value = 'dark';

Apply a saved choice before first paint

Restore a saved preference before your stylesheets load to avoid a flash of the default theme. Match the storage key and allowed values to your control. Choose a theme in this frame, then use its reload link to try it. See the README for cookie restoration and Content Security Policy guidance.

<script>
  try {
    const theme = localStorage.getItem(
      'theme-switcher-early-demo');
    if (['system', 'light', 'dark']
        .includes(theme)) {
      document.documentElement
        .setAttribute('data-theme', theme);
    }
  } catch (error) {
    console.warn('Theme storage unavailable.',
      error);
  }
</script>
<link rel="stylesheet" href="./styles.css">

<!-- In the body: -->
<theme-switcher
  storage-key="theme-switcher-early-demo"
  label="Saved appearance">
</theme-switcher>