Accessible Dark Mode Toggles for Static Sites

A dark theme is now expected on documentation sites, and it is a genuine accessibility feature: some readers with photophobia, migraine or certain visual impairments find dark backgrounds far easier to read. It also doubles the surface on which accessibility can break. The toggle itself is a widget with keyboard and screen reader requirements; the theme it switches to has its own contrast to meet; and the switch has to happen before the first paint, or readers who chose dark get a white flash on every page load — which for a photosensitive reader is the exact thing they were trying to avoid.

This guide builds a toggle for the marketing section of the reference site from Accessibility for Static Sites, whose original toggle was an unlabelled <div> with a moon icon. The finished version is under 1 KB, works in Astro, Eleventy, Hugo and Jekyll layouts, and keeps every colour token at AA contrast in both themes.

Prerequisites

  • Colours defined as CSS custom properties, so a single attribute on <html> can switch them.
  • The ability to add an inline script to the <head> of the base layout.
  • A contrast check for both themes, as in Fixing Low Color Contrast Flagged by Lighthouse.

What Was Wrong With the Old Toggle

ProblemEffectFound by
<div onclick> with a moon SVGNot focusable, no name, no roleaxe, keyboard
Theme applied by a deferred module120–180 ms white flash on darkPerformance panel, frame capture
Ignored prefers-color-schemeDark-mode OS users saw light firstManual
Dark link colour at 3.6:139 contrast failures per sampleaxe in dark mode
Icon did not change with stateNo visual state for anyoneManual

The white flash was measured with Chrome's performance panel and screenshots: with the OS in dark mode and dark stored as the choice, the first frame painted at 140 ms was light, and the dark theme arrived at 290 ms on a mid-range Android phone profile.

Sequence of events on page load with a deferred theme script Three actors, the browser, the stylesheet and the theme script. The browser parses the head and loads the stylesheet with light colours as the default. The browser paints the first frame in light colours at 140 milliseconds. The deferred theme script runs, reads the stored choice and sets data-theme dark. The browser repaints in dark colours at 290 milliseconds. Why the old toggle flashed white Browser Stylesheet Theme script load CSS, light by default first paint: light, 140 ms deferred: set data-theme=dark repaint: dark, 290 ms
Moving the script inline into the head, before the stylesheet, makes the first frame use the right theme.

Step 1: Set the Theme Before the First Paint

An inline script in <head>, before the stylesheet link, runs before anything is painted. It reads the stored choice, falls back to the OS preference, and sets an attribute:

<script>
  (function () {
    var t = null;
    try { t = localStorage.getItem('theme'); } catch (e) {}
    if (t !== 'light' && t !== 'dark')
      t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    document.documentElement.dataset.theme = t;
  })();
</script>
<link rel="stylesheet" href="/styles.css">

The try matters: localStorage throws in some privacy modes, and an exception here would leave the page with no theme at all. The script is 290 bytes and blocks parsing for well under a millisecond. If the site has a Content Security Policy, add the script's SHA-256 hash to script-src, as in Fixing Content Security Policy Violations From Inline Scripts.

Set color-scheme in CSS as well, so native controls, scrollbars and the default canvas follow the theme:

:root { color-scheme: light; }
[data-theme='dark'] { color-scheme: dark; }

Step 2: A Real Button With State

The toggle is a <button> with a visible or visually hidden label and aria-pressed:

<button type="button" class="theme-toggle" aria-pressed="false">
  <span class="icon icon-moon" aria-hidden="true"></span>
  <span class="icon icon-sun" aria-hidden="true"></span>
  <span class="sr-only">Dark theme</span>
</button>
const btn = document.querySelector('.theme-toggle');
const sync = () => btn.setAttribute('aria-pressed', String(document.documentElement.dataset.theme === 'dark'));
btn.addEventListener('click', () => {
  const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
  document.documentElement.dataset.theme = next;
  try { localStorage.setItem('theme', next); } catch (e) {}
  sync();
});
sync();

The accessible name stays constant ("Dark theme") and the state changes, so NVDA announces "Dark theme, toggle button, pressed" or "not pressed". Changing the label instead — "Switch to light mode" — is a common alternative, but combining a changing label with aria-pressed produces contradictory announcements. Pick one; the constant name with state is the clearer of the two.

Screen reader announcements for the old and new theme toggles Two panels. The old div toggle was skipped by Tab, and when reached with the virtual cursor it announced clickable, with no name and no state. The new button announces Dark theme, toggle button, not pressed, and after activation Dark theme, toggle button, pressed. What screen readers announce Old div toggle skipped by Tab "clickable" no name no state unusable without a mouse New button reached by Tab "Dark theme, toggle button" "not pressed" / "pressed" icons hidden from AT name and state announced
Recorded with NVDA 2025.3 on Firefox and VoiceOver on Safari; both gave equivalent output.

Step 3: Offer "Follow System"

A two-state toggle loses one thing: once a reader clicks it, the site no longer follows the OS setting, even if the reader later switches their OS to light in the morning and dark at night. The reference site added a third choice in the footer — a small <select> with Light, Dark and System — and kept the header button as the quick toggle. Choosing System removes the stored value, so the inline script falls back to prefers-color-scheme again. A change listener on the media query updates the theme live when the OS switches, but only while no explicit choice is stored.

Step 4: Check Both Themes for Contrast

Switching the theme only helps if the dark theme is readable. Every text token was checked in both themes with the token test from the contrast guide, and the axe page scan was run once per theme by setting localStorage in an init script before navigation. The dark theme's link colour failed at 3.6:1 and was raised to 6.2:1; two badge colours and the code comment colour were also adjusted. Inline SVG diagrams need their own dark treatment — the site recolours their backgrounds and neutral labels through CSS attribute selectors on the palette colours, covered in Accessible SVG Diagrams on Static Sites.

Step 5: Respect Reduced Motion

The old toggle animated a full-page colour transition over 400 ms. Some readers with vestibular disorders set prefers-reduced-motion, and a page-wide fade can be uncomfortable. The transition now runs only when motion is allowed, and it is limited to background and text colours rather than every property:

@media (prefers-reduced-motion: no-preference) {
  body { transition: background-color 150ms, color 150ms; }
}

The 150 ms transition also avoids animating on first load, because the inline script sets the theme before the body exists.

Theme timing and dark contrast failures before and after Paired bars. Time until the correct theme is shown went from 290 to 140 milliseconds, the same as first paint. The wrong-theme flash went from 150 to 0 milliseconds. Contrast failures in dark mode went from 39 to 0. Load behaviour before and after Before After Correct theme shown (ms) 290 140 Wrong-theme flash (ms) 150 0 Dark contrast failures 39 0
Timings from Chrome's performance panel on a mid-range Android profile with dark mode stored.

Testing the Toggle

A short Playwright test covers the behaviour that matters most and is easiest to break:

  1. With colorScheme: 'dark' in the browser context and nothing stored, the first screenshot shows the dark theme.
  2. Pressing Tab reaches the toggle; pressing Enter switches the theme and sets aria-pressed="true".
  3. After reload, the choice persists.
  4. axe reports no button-name or color-contrast violations in either theme.

Playwright's emulateMedia({ colorScheme }) makes the first test reliable; it runs in about 6 seconds for both themes.

Measured Impact

MeasureBeforeAfterTool
Wrong-theme flash on dark150 ms0Chrome performance panel
Toggle reachable by keyboardNoYesKeyboard
button-name violations1 per page0axe-core 4.10
Dark theme contrast failures390axe-core 4.10
Toggle script size3.2 KB0.9 KB incl. inlineBuild output

Pitfalls & Rollback

  • Applying the theme in a deferred script. It must run inline in <head>.
  • No try around localStorage. One exception leaves the page unthemed.
  • Changing both the label and aria-pressed. Use one mechanism for state.
  • Checking contrast only in light mode. Run the scan per theme.
  • Rollback: the toggle is one component and one inline script; removing both falls back to the light theme with prefers-color-scheme honoured by CSS if a media-query fallback is kept in the stylesheet.

Conclusion

An accessible theme toggle is a small piece of code with several easy mistakes: it must set the theme before the first paint, be a real button with a name and a state, follow the OS until the reader chooses otherwise, and switch to a theme that has itself been checked for contrast. On the reference site, the new toggle removed a 150 ms white flash and 40 axe violations while shrinking the script from 3.2 KB to 0.9 KB.

FAQ

Should a theme toggle be a button or a switch?

A button with aria-pressed works well for a two-state light and dark toggle, and every screen reader announces it as a toggle button with its state. A role switch is also valid but has slightly less consistent support. For three states, light, dark and system, use a group of radio buttons or a select, because a single button cannot express three states clearly.

Why does my site flash white before switching to dark?

The theme is applied by a script that runs after the first paint, usually one loaded at the end of the body or as a module. Put a tiny inline script in the head, before the stylesheet, that reads the stored choice or the OS preference and sets the data-theme attribute on the html element before anything renders.

Should the toggle respect prefers-color-scheme?

Yes, on first visit. Readers who set their operating system to dark mode have already expressed a preference, and some of them need it for photosensitivity or migraine. The toggle should override it only when the reader chooses, and offering a way back to following the system is good practice.

Does an inline head script conflict with a Content Security Policy?

It does if the policy forbids inline scripts. Allow it with a hash of the script in the script-src directive, which most generators can compute at build time, rather than adding unsafe-inline. The script is stable, so the hash rarely changes.

Filed underHow-tos