Accessibility pattern · Interaction and motion

Theme switcher

Three native radio buttons pick Light, Dark or System; the choice sets one attribute on the page's root, and the stylesheet does the rest. System follows the device and changes with it, color-scheme brings the browser's own controls and scrollbars along, and a tiny script in the head stops a flash of the wrong theme.

WCAG criteria
7
Keyboard rules
4
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Nukkad Café

Appearance

This preview keeps its own theme, separate from the Light and Dark buttons above the stage.

Theme

Your device is set to light.

Your order

On its way

Masala chai × 2, vada pav × 1

Arrives in about 12 minutes. Track the rider

  • Filter coffee, idli 28 Sep
  • Masala chai × 3 21 Sep
  • Bun maska, ginger tea 14 Sep
  • Poha, filter coffee 7 Sep
  • Vada pav × 2 31 Aug
Contrast in both themes, from the design tokens
Pair Light Dark
Body text text / surface 17.72:1 AAA 16.97:1 AAA
Secondary text text-3 / surface 6.42:1 AA 6.91:1 AA
Links accent-text / surface 7.90:1 AAA 8.89:1 AAA
Button label on-accent / accent 6.29:1 AA 6.53:1 AA
Control edges border-strong / surface 4.50:1 AA 5.46:1 AA
No flash on load: in the head, before the stylesheets
<script>
                            const saved = localStorage.getItem("theme-choice");
                            if (saved) document.documentElement.dataset.theme = saved;
                            </script>

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
TabMoves to the selected theme; the next Tab leaves the group for the rest of the page.
Arrow Right or Arrow DownSelects the next theme, wrapping from System to Light, and the page changes at once.
Arrow Left or Arrow UpSelects the previous theme, wrapping from Light to System.
SpaceSelects the focused theme, if none is selected yet.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Tab reaches the groupTheme, group. Your device is set to light. System, radio button, checked, 3 of 3
Arrow Right selects LightLight, radio button, checked, 1 of 3. Light theme on
Arrow Right selects DarkDark, radio button, checked, 2 of 3. Dark theme on
System is selected on a device set to darkSystem, radio button, checked, 3 of 3. Theme follows your device: dark
The device switches while System is selectedNothing: the page changes, but nobody here asked for it

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-theme-switcher" data-ap-theme-switcher data-theme="system">
  <div class="ap-theme-switcher__appbar">
    <span class="ap-theme-switcher__logo" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M5 8h11v6a5 5 0 0 1-5 5h-1a5 5 0 0 1-5-5Z"/><path d="M16 9h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5c0 1 1 1 1 2M11.5 3.5c0 1 1 1 1 2"/></svg></span>
    <span class="ap-theme-switcher__brand">Nukkad Café</span>
    <span class="ap-theme-switcher__avatar" aria-hidden="true" translate="no">AR</span>
  </div>

  <div class="ap-theme-switcher__body">
    <div class="ap-theme-switcher__settings">
      <h3 class="ap-theme-switcher__name">Appearance</h3>
      <p class="ap-theme-switcher__own">This preview keeps its own theme, separate from the Light and Dark buttons above the stage.</p>
      <fieldset class="ap-theme-switcher__set" aria-describedby="theme-switcher-device">
        <legend class="ap-theme-switcher__legend">Theme</legend>
        <div class="ap-theme-switcher__options">
          <label class="ap-theme-switcher__opt">
            <input class="ap-theme-switcher__radio" type="radio" name="theme-switcher-theme" value="light" />
            <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M4.6 4.6 6 6M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4"/></svg>
            <span>Light</span>
          </label>
          <label class="ap-theme-switcher__opt">
            <input class="ap-theme-switcher__radio" type="radio" name="theme-switcher-theme" value="dark" />
            <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"/></svg>
            <span>Dark</span>
          </label>
          <label class="ap-theme-switcher__opt">
            <input class="ap-theme-switcher__radio" type="radio" name="theme-switcher-theme" value="system" checked />
            <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
            <span>System</span>
          </label>
        </div>
      </fieldset>
      <p class="ap-theme-switcher__device" id="theme-switcher-device" data-ap-device>Your device is set to light.</p>
      <p class="ap-theme-switcher__sr" role="status" data-ap-theme-status></p>
    </div>

    <div class="ap-theme-switcher__order">
      <div class="ap-theme-switcher__order-top">
        <h4 class="ap-theme-switcher__order-name">Your order</h4>
        <span class="ap-theme-switcher__chip"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>On its way</span>
      </div>
      <p class="ap-theme-switcher__items">Masala chai × 2, vada pav × 1</p>
      <p class="ap-theme-switcher__eta">Arrives in about 12 minutes. <a href="#rider">Track the rider</a></p>
      <div class="ap-field">
        <label class="ap-label" for="theme-switcher-note">Note for the rider</label>
        <input class="ap-input" id="theme-switcher-note" type="text" value="Gate 2, ring once" />
      </div>
      <div class="ap-theme-switcher__controls">
        <label class="ap-theme-switcher__check"><input type="checkbox" checked /> Leave at the door</label>
        <label class="ap-theme-switcher__tip">Rider tip
          <select>
            <option>None</option>
            <option selected>₹20</option>
            <option>₹50</option>
          </select>
        </label>
      </div>
      <div class="ap-theme-switcher__past" role="region" aria-label="Past orders" tabindex="0">
        <ul>
          <li><span>Filter coffee, idli</span> <span class="ap-theme-switcher__when" translate="no">28 Sep</span></li>
          <li><span>Masala chai × 3</span> <span class="ap-theme-switcher__when" translate="no">21 Sep</span></li>
          <li><span>Bun maska, ginger tea</span> <span class="ap-theme-switcher__when" translate="no">14 Sep</span></li>
          <li><span>Poha, filter coffee</span> <span class="ap-theme-switcher__when" translate="no">7 Sep</span></li>
          <li><span>Vada pav × 2</span> <span class="ap-theme-switcher__when" translate="no">31 Aug</span></li>
        </ul>
      </div>
    </div>
  </div>

  <div class="ap-theme-switcher__contrast-wrap">
    <table class="ap-theme-switcher__contrast">
      <caption>Contrast in both themes, from the design tokens</caption>
      <thead>
        <tr>
          <th scope="col">Pair</th>
          <th scope="col" data-col="light">Light <span class="ap-theme-switcher__now" aria-hidden="true">Now</span></th>
          <th scope="col" data-col="dark">Dark <span class="ap-theme-switcher__now" aria-hidden="true">Now</span></th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th scope="row"><span>Body text</span> <code>text / surface</code></th>
          <td data-col="light"><span translate="no">17.72:1</span> <span class="ap-theme-switcher__lvl" translate="no">AAA</span></td>
          <td data-col="dark"><span translate="no">16.97:1</span> <span class="ap-theme-switcher__lvl" translate="no">AAA</span></td>
        </tr>
        <tr>
          <th scope="row"><span>Secondary text</span> <code>text-3 / surface</code></th>
          <td data-col="light"><span translate="no">6.42:1</span> <span class="ap-theme-switcher__lvl" translate="no">AA</span></td>
          <td data-col="dark"><span translate="no">6.91:1</span> <span class="ap-theme-switcher__lvl" translate="no">AA</span></td>
        </tr>
        <tr>
          <th scope="row"><span>Links</span> <code>accent-text / surface</code></th>
          <td data-col="light"><span translate="no">7.90:1</span> <span class="ap-theme-switcher__lvl" translate="no">AAA</span></td>
          <td data-col="dark"><span translate="no">8.89:1</span> <span class="ap-theme-switcher__lvl" translate="no">AAA</span></td>
        </tr>
        <tr>
          <th scope="row"><span>Button label</span> <code>on-accent / accent</code></th>
          <td data-col="light"><span translate="no">6.29:1</span> <span class="ap-theme-switcher__lvl" translate="no">AA</span></td>
          <td data-col="dark"><span translate="no">6.53:1</span> <span class="ap-theme-switcher__lvl" translate="no">AA</span></td>
        </tr>
        <tr>
          <th scope="row"><span>Control edges</span> <code>border-strong / surface</code></th>
          <td data-col="light"><span translate="no">4.50:1</span> <span class="ap-theme-switcher__lvl" translate="no">AA</span></td>
          <td data-col="dark"><span translate="no">5.46:1</span> <span class="ap-theme-switcher__lvl" translate="no">AA</span></td>
        </tr>
      </tbody>
    </table>
  </div>

  <figure class="ap-theme-switcher__head">
    <figcaption>No flash on load: in the head, before the stylesheets</figcaption>
    <pre><code>&lt;script&gt;
<span class="ap-theme-switcher__in">const saved = localStorage.getItem("theme-choice");</span>
<span class="ap-theme-switcher__in">if (saved) document.documentElement.dataset.theme = saved;</span>
&lt;/script&gt;</code></pre>
  </figure>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    A fieldset and legend group the three choices under Theme, and the line about the device describes the group.

  • 1.4.3 Contrast (Minimum) Level AA

    Every text pair meets 4.5:1 in both themes; the table shows the ratios, measured from the design tokens.

  • 1.4.11 Non-text Contrast Level AA

    Control edges, the focus ring and the selected option's border stay at 3:1 or more in both themes.

  • 2.1.1 Keyboard Level A

    Native radio buttons answer to Tab and the arrow keys, so the switcher needs no key handling of its own.

  • 3.2.2 On Input Level A

    Choosing a theme changes the colors only; focus stays on the radio button and nothing else on the page moves.

  • 4.1.2 Name, Role, Value Level A

    Each option is a labelled radio input, so its name, its place in the set and its checked state come from the browser.

  • 4.1.3 Status Messages Level AA

    A polite status message confirms a choice the person made, and stays quiet when the device changes the theme on its own.

Usage

When to use it

Use it

  • Sites and apps with a light and a dark design, where people may want something other than their device's setting.
  • Settings pages and site headers; a header usually shows the same three choices behind a menu button.

Use something else

  • Sites with one theme: following prefers-color-scheme without a control is enough if you offer both designs.
  • A single on and off toggle for dark mode: it cannot say "follow my device", which is what most people want.

Common failures

How it usually goes wrong

  • A flash of the wrong theme

    A choice applied after the page loads paints the default first, which glares in the dark. A blocking script in the head sets the attribute before the first paint.

  • No System option

    Light and Dark alone pin the page, so it no longer follows the device at sunset. System is the default here and changes live.

  • Dark backgrounds with light controls

    Without color-scheme, checkboxes, selects and scrollbars stay bright on a dark page. Setting it lets the browser draw its own controls to match.

  • Contrast checked in one theme only

    A muted gray that passes on white can fail on near-black. Every pair here is measured in both themes, from the same tokens.

  • Announcing changes nobody asked for

    Reading out "Dark theme on" because the device switched at sunset interrupts whatever the person was doing. Only a choice made here is announced.

  • A toggle that shows the wrong state

    A sun icon that means "switch to light" on one site and "light is on" on another confuses everyone. Radio buttons show which theme is chosen, with the name in text.

Notes

Building it

  • On a real page the script sets data-theme on document.documentElement, and the tokens stylesheet (Tokens tab) already switches on :root[data-theme] and prefers-color-scheme. Here the demo's own root stands in for it.
  • The demo's stylesheet holds both palettes as light-dark() pairs and sets color-scheme from data-theme; with System, both schemes are allowed and the device decides, live, with no script involved.
  • The script listens to matchMedia("(prefers-color-scheme: dark)") only to keep the device line and the System announcement accurate.
  • With Remember the choice on, the pick is saved in localStorage as theme-choice. The head script shown in the demo reads that key before any CSS loads, so the first paint is already right.
  • The Track the rider link stands for another page; in the copied code it is an ordinary link.

Sources: Media Queries Level 5: prefers-color-scheme · CSS Color Adjustment: the color-scheme property · WAI-ARIA Authoring Practices: Radio Group

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction