Theme switch

fieldset.segmented[data-ui="theme-switch"]

Theme switch — light, dark, or whatever the machine says.

Examples

Light, dark, system

View “Light, dark, system” as
Theme
<fieldset class="segmented segmented-sm segmented-icon" data-ui="theme-switch">
  <legend class="sr-only">Theme</legend>
  <input class="segmented-input" type="radio" name="demo-theme" id="demo-theme-light" value="light" />
  <label class="segmented-item" for="demo-theme-light">
    <svg class="icon" aria-hidden="true"><use href="#icon-sun"></use></svg>
    <span class="sr-only">Light</span>
  </label>
  <input class="segmented-input" type="radio" name="demo-theme" id="demo-theme-dark" value="dark" />
  <label class="segmented-item" for="demo-theme-dark">
    <svg class="icon" aria-hidden="true"><use href="#icon-moon"></use></svg>
    <span class="sr-only">Dark</span>
  </label>
  <input class="segmented-input" type="radio" name="demo-theme" id="demo-theme-system" value="system" checked />
  <label class="segmented-item" for="demo-theme-system">
    <svg class="icon" aria-hidden="true"><use href="#icon-monitor"></use></svg>
    <span class="sr-only">System</span>
  </label>
</fieldset>

There is no theme-switch stylesheet: this is segmented with .segmented-icon, and data-ui="theme-switch" adds the only part CSS cannot do — writing data-theme on the root and remembering it.

All three states are visible at once, which is what makes it read as a switch rather than as a stray form field. The third position matters more than it looks: without it, touching the control once stops the page following the machine for good, sunset schedule included.

The names are still there

View “The names are still there” as
Theme
<fieldset class="segmented segmented-sm" data-ui="theme-switch">
  <legend class="sr-only">Theme</legend>
  <input class="segmented-input" type="radio" name="demo-theme-words" id="demo-theme-w-light" value="light" />
  <label class="segmented-item" for="demo-theme-w-light">
    <svg class="icon" aria-hidden="true"><use href="#icon-sun"></use></svg>
    Light
  </label>
  <input class="segmented-input" type="radio" name="demo-theme-words" id="demo-theme-w-dark" value="dark" />
  <label class="segmented-item" for="demo-theme-w-dark">
    <svg class="icon" aria-hidden="true"><use href="#icon-moon"></use></svg>
    Dark
  </label>
  <input class="segmented-input" type="radio" name="demo-theme-words" id="demo-theme-w-system" value="system" checked />
  <label class="segmented-item" for="demo-theme-w-system">
    <svg class="icon" aria-hidden="true"><use href="#icon-monitor"></use></svg>
    System
  </label>
</fieldset>

Drop .segmented-icon and the .sr-only wrapper and the same strip carries visible words beside the glyphs.

This is the version for a settings panel, where the control belongs to a column of labelled fields, and for readers who do not share the sun/moon convention. In the icon-only version above, that text has not gone away — it has moved into .sr-only, because a radio group of three unnamed options announces as "radio button, 1 of 3" three times.

As a select

View “As a select” as
<label class="field-label" for="demo-theme-select">Theme</label>
<select class="select select-sm" id="demo-theme-select" data-ui="theme-switch">
  <option value="system" selected>System</option>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>

Still supported, and no longer the recommendation. A select hides two of the three states behind a click, so nothing about it says a choice is available until you operate it — and it is wider than the icon strip, not narrower, which was the old argument for it.

Reach for it when the platform picker on a phone is the point. Every control on this page stays in step through ui:themechange, which is easier to see than to describe: change one.

Tokens 0

Level 2, declared on .theme-switch itself. Set any of them on that selector to restyle this component without touching the skin.

None. It reads the skin's tokens directly.

Variants and states

Variants

None. It is one shape, and the page does the rest.

State it reads

None.

Before you ship it

This one needs a script. Opt the markup in with data-ui="theme-switch" and import import 'mostlycss/js/components/theme-switch' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.

What you have to do 6 requirements

  1. Name the control. A <label> for the select; a <legend> inside the <fieldset>, or role="group" with aria-label, for the strip.
  2. Name each option of the icon strip. A <span class="sr-only"> inside every <label>, saying Light, Dark and System. The <svg> is aria-hidden and carries no name; nothing in CSS can supply one and nothing detects its absence at runtime.
  3. Copy the sprite, and point href at where you serve it. The three glyphs are #icon-sun, #icon-moon and #icon-monitor in dist/sprite.svg, which no server serves until you run npx mostlycss sprite --out public. A missing sprite renders three empty boxes and reports nothing.
  4. Give one option the initial state in the markup, normally system: selected on the <option>, checked on the radio. An empty radio group makes the first Tab land on the first segment rather than on the chosen one — and until this module runs, the markup is the whole truth.
  5. Inline themeInitScript() in the <head>. Without it the switch works and every navigation flashes the wrong theme first.
  6. Do not also set data-theme from your own script. Two writers of one attribute disagree the first time someone uses two tabs.

src/js/components/theme-switch.ts · npx mostlycss add theme-switch