Building an Accessible Theme Toggle Component

Part of Runtime Theme Switching. The switching mechanism is settled; the control that drives it is where the accessibility work is. This guide covers which pattern to choose, what to announce, and the details that decide whether the control is usable without sight or without a mouse.

Three control patterns for theme selection A two-state toggle button, a switch, and a three-option radio group compared on what each communicates, what it announces, and when it is the right choice. Toggle button button + aria-pressed two states only best for: a compact header announces "dark theme, toggle button, pressed" Switch role="switch" + aria-checked reads as on or off best for: a settings panel implies dark is a feature being turned on Radio group light / dark / system three states, one chosen best for: honouring the OS the only pattern that can express "follow the system"
The third state is the deciding factor. A two-state control cannot express "follow my system preference", which is what a visitor wants after they have changed their mind.

Prerequisites Link to this section

  • A theme resolver that reads a stored choice, falls back to the OS preference, and writes an attribute on the root element.
  • A place in the layout for the control that is reachable early in the tab order; a theme control at the very end of a long page is technically accessible and practically not.
  • A screen reader available for testing — VoiceOver, NVDA or Narrator; the announcements differ between them and only real testing settles what they say.

Step-by-Step Implementation Link to this section

Step 1 — Choose the pattern honestly Link to this section

Intent: pick the control that matches the number of states the system actually has.

If storage can hold three values — light, dark and absent-meaning-system — the control has three states and should be a radio group. If the product deliberately supports only two, a toggle button is correct and simpler. What is not correct is a two-state control over three-state storage, which produces the well-known complaint that the site stopped following the system after one click and there is no way back.

<fieldset class="theme-choice">
  <legend>Colour theme</legend>

  <input type="radio" name="theme" id="theme-light" value="light">
  <label for="theme-light">Light</label>

  <input type="radio" name="theme" id="theme-dark" value="dark">
  <label for="theme-dark">Dark</label>

  <input type="radio" name="theme" id="theme-system" value="system" checked>
  <label for="theme-system">Match system</label>
</fieldset>

Why this works: native radio inputs bring grouping, arrow-key navigation, a single tab stop for the group and a correct announcement — “Colour theme, Match system, radio button, 3 of 3, selected” — with no ARIA at all. Reimplementing that with buttons and aria-checked is a common and unnecessary source of defects.

Step 2 — If it must be a button, use aria-pressed correctly Link to this section

Intent: a compact header control that toggles between two states needs the pressed state exposed, not implied by an icon.

<button type="button" id="theme-toggle" aria-pressed="false">
  <svg class="icon-sun" aria-hidden="true" focusable="false"><!-- … --></svg>
  <svg class="icon-moon" aria-hidden="true" focusable="false"><!-- … --></svg>
  <span class="visually-hidden">Dark theme</span>
</button>

Why this works: the accessible name stays constant — “Dark theme” — and aria-pressed carries the state, so a screen reader announces “Dark theme, toggle button, pressed” or “not pressed”. This is the pattern that behaves predictably across screen readers, because the name identifies what the control is for and the state is a separate, standardised property.

Step 3 — Do not change the name and the state together Link to this section

Intent: avoid the most common mistake in theme toggles, which produces contradictory announcements.

// Wrong: name and state both change, so the control announces
// "Switch to light theme, pressed" — which reads as already done.
btn.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme');
btn.setAttribute('aria-pressed', String(dark));

// Right: constant name, state carries the change.
btn.setAttribute('aria-label', 'Dark theme');
btn.setAttribute('aria-pressed', String(dark));

Why this works: aria-pressed already means “this is currently on”. A name that also describes an action produces a sentence a listener has to unpick — and different screen readers order the name and state differently, so the resulting phrase varies by product. Naming the thing rather than the action is the rule that makes the announcement stable everywhere.

Step 4 — Announce the change, if anything Link to this section

Intent: decide deliberately whether a theme change warrants a live region announcement.

<p class="visually-hidden" role="status" id="theme-status"></p>
function announce(theme) {
  const status = document.getElementById('theme-status');
  if (status) status.textContent = `${theme === 'dark' ? 'Dark' : 'Light'} theme applied`;
}

Why this works, with a caveat: a role="status" region is announced politely, after whatever the screen reader is currently saying. For a toggle button the state change is already announced by the button itself, so this is redundant and adds noise. It earns its place with the radio group, where selecting an option does not otherwise confirm that anything happened, and for the “match system” option in particular, where the visible result may be no change at all.

What a screen reader announces for each pattern Announcement strings for a correctly built toggle button and for a toggle whose accessible name changes with state, showing why the second is confusing. constant name: "Dark theme, toggle button, not pressed" → press → "pressed" changing name: "Switch to dark theme, not pressed" → press → "Switch to light theme, pressed" the second reads as: an instruction that is somehow already completed radio group: "Colour theme, Match system, radio button, 3 of 3, selected"
The contradiction in the second row is subtle in text and obvious in speech. It is the single most common accessibility defect in theme controls.

Step 5 — Keep the control usable without a pointer Link to this section

Intent: every state must be reachable and visible via keyboard alone.

.theme-choice input:focus-visible + label,
#theme-toggle:focus-visible {
  outline: 2px solid var(--ds-color-focus);
  outline-offset: 3px;
}

/* The focus ring must survive both themes and forced colours. */
@media (forced-colors: active) {
  .theme-choice input:focus-visible + label,
  #theme-toggle:focus-visible { outline: 2px solid CanvasText; }
}

Why this works: a focus ring built from a theme token disappears in forced-colors mode, where system colours replace the palette. Declaring the fallback explicitly keeps the control operable in the mode where keyboard use is most likely, which is the same reasoning covered in forced-colors and high contrast.

Step 6 — Sync the control with the resolved state on load Link to this section

Intent: the control must reflect the theme that the head script already applied.

function syncControl() {
  const resolved = document.documentElement.getAttribute('data-theme');
  let stored = null;
  try { stored = localStorage.getItem('theme'); } catch (_) {}

  const value = stored === 'light' || stored === 'dark' ? stored : 'system';
  const input = document.querySelector(`input[name="theme"][value="${value}"]`);
  if (input) input.checked = true;

  const btn = document.getElementById('theme-toggle');
  if (btn) btn.setAttribute('aria-pressed', String(resolved === 'dark'));
}

document.addEventListener('DOMContentLoaded', syncControl);

Why this works: the distinction between the stored choice and the resolved theme is what the radio group needs. A visitor on “match system” whose OS is dark should see “Match system” selected and a dark interface — not “Dark” selected, which would misreport what they chose and make the system option look inert.

Verification Link to this section

Four checks, and the third is the one usually skipped.

Navigate to the control with the keyboard only and confirm every option is reachable and the focus indicator is clearly visible in both themes. Operate it with a screen reader and write down the exact announcement before and after activation; it should name the control, state its current state, and not contradict itself. Load the page with the OS set to dark and no stored choice, and confirm the control shows “match system” rather than “dark”. Finally, enable forced-colors mode and confirm the control is still visible and its state still distinguishable — a toggle whose only state indicator is a background colour becomes indistinguishable there.

Stored choice against resolved theme A table mapping three stored values against two operating system preferences, showing the resolved theme and what the control should display in each case. stored value OS preference resolved control shows absent dark dark Match system "light" dark light Light "dark" light dark Dark
The control reflects what the visitor chose, not what is currently rendered. Conflating the two is what makes the "match system" option look broken.

Troubleshooting Link to this section

Symptom Likely cause Fix
The screen reader announcement contradicts itself The accessible name changes along with aria-pressed Keep the name constant; let the state carry the change
The control shows “Dark” when the visitor chose “match system” The control is synced from the resolved theme, not from storage Sync from the stored value; the resolved theme drives the page only
The toggle is invisible in high contrast mode The state indicator is a background colour that forced-colors replaces Add a border or icon difference, and a system-colour focus ring
Arrow keys do not move between options Buttons with aria-checked were used instead of native radios Use real radio inputs, or implement full roving tabindex
The state resets on navigation The control syncs on load but the resolver runs later Ensure the head script sets the attribute before the control syncs

Where the Control Should Live Link to this section

Placement is part of accessibility, not a separate design question, and it is worth thinking about before the markup is written.

A theme control belongs somewhere a visitor can find it without exploring — which in practice means the header, near the other global controls, or a clearly labelled settings area reachable from the header. Burying it in a footer is technically accessible and practically undiscoverable for someone navigating by landmark or heading.

If it lives in the header, keep it out of the first tab stop. The first stop should be the skip link; the second should generally be the primary navigation. A theme toggle that intercepts the tab order before either is a small daily cost for every keyboard user, in exchange for a control most visitors use once.

If it lives in a settings panel, the radio group pattern becomes clearly correct: there is room for three labelled options, the grouping is visible, and the legend gives the group a name that a screen reader announces on entry. The compact toggle only exists because header space is scarce, so where space is not scarce, the better pattern is available.

One more consideration: whichever placement is chosen, the control’s state must be correct on every page. A control in the header that syncs from storage on load handles this automatically; one built as part of a client-side route may not re-sync on navigation, which produces a control that gradually drifts out of step with the interface it claims to describe.

Migration Note Link to this section

Upgrading a two-state toggle to a three-state control is a small code change with one behavioural decision behind it: what to do with the choices already stored.

The safe migration treats every existing stored value as an explicit choice, because that is what it was — the visitor pressed the button. They keep their theme and see the corresponding option selected. What changes is that the “match system” option now exists, so they have a way back to following the OS that they did not have before.

The alternative, clearing storage so everyone starts on “match system”, is tempting because it produces a cleaner default, and it silently overrides a real preference for every existing visitor. It is worth doing only if the stored values are known to be unreliable — for instance, if the old toggle wrote a value on page load rather than on interaction, which some implementations did.