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
| Problem | Effect | Found by |
|---|---|---|
<div onclick> with a moon SVG | Not focusable, no name, no role | axe, keyboard |
| Theme applied by a deferred module | 120–180 ms white flash on dark | Performance panel, frame capture |
Ignored prefers-color-scheme | Dark-mode OS users saw light first | Manual |
| Dark link colour at 3.6:1 | 39 contrast failures per sample | axe in dark mode |
| Icon did not change with state | No visual state for anyone | Manual |
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.
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.
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.
Testing the Toggle
A short Playwright test covers the behaviour that matters most and is easiest to break:
- With
colorScheme: 'dark'in the browser context and nothing stored, the first screenshot shows the dark theme. - Pressing Tab reaches the toggle; pressing Enter switches the theme and sets
aria-pressed="true". - After reload, the choice persists.
- axe reports no
button-nameorcolor-contrastviolations in either theme.
Playwright's emulateMedia({ colorScheme }) makes the first test reliable; it runs in about 6 seconds for both themes.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Wrong-theme flash on dark | 150 ms | 0 | Chrome performance panel |
| Toggle reachable by keyboard | No | Yes | Keyboard |
button-name violations | 1 per page | 0 | axe-core 4.10 |
| Dark theme contrast failures | 39 | 0 | axe-core 4.10 |
| Toggle script size | 3.2 KB | 0.9 KB incl. inline | Build output |
Pitfalls & Rollback
- Applying the theme in a deferred script. It must run inline in
<head>. - No
tryaroundlocalStorage. 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-schemehonoured 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.
Related
- Parent: Accessibility for Static Sites — themes among the visual design issues.
- Fixing Low Color Contrast Flagged by Lighthouse — the token test for both themes.
- Keyboard Navigation and Focus Order on Static Sites — focus rings that work in dark mode.
- Accessible SVG Diagrams on Static Sites — recolouring diagrams for the dark theme.
- Fixing Content Security Policy Violations From Inline Scripts — allowing the inline theme script by hash.
Filed underHow-tos