light-dark-mode — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited light-dark-mode (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Canonical source:examples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.mdinmgifford/ACCESSIBILITY.mdThis skill is derived from that file. When in doubt, the example is authoritative.
Apply these rules whenever implementing or reviewing colour theme support in HTML, CSS, or JavaScript. Only load this skill if the project supports light/dark mode or user theme switching.
A theme must not change until the user explicitly activates an option.
Moving keyboard focus or pointer hover across theme choices MUST NOT preview, select, or apply a theme. Tabbing across System, Light, and Dark MUST leave the page colours unchanged until the user presses Enter or Space.
This is a Critical requirement. Violations cause colour flashing during keyboard navigation and disorient users with vestibular disorders, cognitive disabilities, and low vision.
All colour themes must meet WCAG 2.2 Level AA contrast in both light and dark modes, including forced-colours / high contrast modes. Test all three — not just the default.
Use a visible three-option selector that shows all three options at the same time:
<button type="button"> elementsaria-pressed="true" for the selected option and aria-pressed="false" for othersrole="radio" elementsThe selector MUST work with standard native button behaviour. Users MUST be able to:
Moving focus between the options MUST NOT change the theme. The theme MUST change only after deliberate activation.
Do NOT add unnecessary custom keyboard handlers where native button behaviour already provides the required functionality.
Theme changes MUST occur only after explicit activation. Moving keyboard focus or pointer hover across theme choices MUST NOT preview, select, or apply a theme.
Tabbing across System, Light, and Dark MUST leave the page colours unchanged until the user presses Enter or Space.
Use a group with an accessible label containing three native buttons:
<div role="group" aria-label="Colour theme">
<button
type="button"
class="theme-mode-btn"
aria-pressed="false"
data-theme-value="system">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<path fill="none" stroke="currentColor" stroke-width="2" d="M3 4h18v12H3zM8 20h8"/>
</svg>
<span>System</span>
</button>
<button
type="button"
class="theme-mode-btn"
aria-pressed="false"
data-theme-value="light">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<circle cx="12" cy="12" r="5" fill="currentColor"/>
</svg>
<span>Light</span>
</button>
<button
type="button"
class="theme-mode-btn"
aria-pressed="true"
data-theme-value="dark">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
<span>Dark</span>
</button>
</div>Use system as the default preference when the user has not made an explicit choice.
Do NOT convert the system preference into a stored light or dark preference.
For example, when the user selects system while their operating system is currently dark, store:
systemDo NOT store:
darkThis distinction is required so that later operating-system changes continue to affect the page.
Use a clearly named storage key such as theme-mode. Valid stored values are only: system, light, dark.
localStorage reads and writes in try/catchStorage can be unavailable in:
Maintain separate values for the selected mode and resolved theme:
selectedMode = "system" | "light" | "dark";
resolvedTheme = "light" | "dark";Apply both to the root element using explicit attributes:
<html data-theme-mode="system" data-theme="dark">data-theme-mode represents the user's actual selectiondata-theme represents the currently rendered light or dark schemeWhen system is active, listen for changes to:
window.matchMedia("(prefers-color-scheme: dark)")Update the resolved theme when that preference changes. Do NOT overwrite the stored mode when the system preference changes.
Provide an early initialization pattern that runs before the primary stylesheet or before the page is visibly rendered. The pattern MUST:
prefers-color-schemecolor-scheme valueInclude early in <head>:
<meta name="color-scheme" content="light dark">Explain that this early script is intended to reduce a flash of the incorrect colour scheme during page load. Do NOT claim that a script can eliminate every possible visual transition in every browser. Describe it as reducing or preventing the common flash caused by applying the preference too late.
Use semantic theme tokens through CSS custom properties. Do NOT hard-code component colours independently throughout the example.
:root {
color-scheme: light dark;
--color-text: #1a1a1a;
--color-background: #ffffff;
--color-surface: #f7f7f7;
--color-link: #0066cc;
--color-focus: #004499;
--color-border: #cccccc;
--color-hover: #f5f5f5;
}
@media (prefers-color-scheme: dark) {
:root {
--color-text: #e8e8e8;
--color-background: #121212;
--color-surface: #1b1b1b;
--color-link: #66aaff;
--color-focus: #9bd1ff;
--color-border: #444444;
--color-hover: #262626;
}
}
@supports (color: light-dark(#000, #fff)) {
:root {
--color-text: light-dark(#1a1a1a, #e8e8e8);
--color-background: light-dark(#ffffff, #121212);
--color-surface: light-dark(#f7f7f7, #1b1b1b);
--color-link: light-dark(#0066cc, #66aaff);
--color-focus: light-dark(#004499, #9bd1ff);
--color-border: light-dark(#cccccc, #444444);
--color-hover: light-dark(#f5f5f5, #262626);
}
}
/* Manual overrides narrow the active scheme when a user chooses one. */
[data-theme="light"] {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}Component styles MUST read from the tokens above rather than hardcode colour values.
The selected state MUST NOT be communicated through colour alone. Use multiple cues such as:
aria-pressedFocus and selected state MUST be visually distinguishable from one another. A focused but unselected option MUST NOT look identical to the selected option.
.theme-mode-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-surface);
color: var(--color-text);
font-size: 1rem;
cursor: pointer;
}
/* Focus indicator — visible outline, not box-shadow */
.theme-mode-btn:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* Selected state — distinct from focus */
.theme-mode-btn[aria-pressed="true"] {
border-color: var(--color-link);
font-weight: 600;
background: var(--color-background);
}Use :focus-visible with a real outline. Do NOT remove the outline unless it is replaced by an equal or stronger visible focus indicator.
The selector MUST remain usable in Windows High Contrast Mode and other forced-colours environments. Do NOT rely only on:
Use system colours where appropriate within @media (forced-colors: active).
Ensure that:
currentColor remain visibleDo NOT use forced-color-adjust: none unless there is a specific, documented reason and the resulting colours are tested.
@media (forced-colors: active) {
.theme-mode-btn {
border: 2px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.theme-mode-btn:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.theme-mode-btn[aria-pressed="true"] {
border-color: Highlight;
forced-color-adjust: none;
background: Highlight;
color: HighlightText;
}
}Use visible text labels: System, Light, Dark. Icons MAY supplement the labels but MUST NOT replace them.
Decorative icons MUST use:
<svg aria-hidden="true" focusable="false">...</svg>Do NOT dynamically replace stable button labels with phrases such as:
The button label SHOULD describe the preference the button selects. The selected state is communicated through aria-pressed.
It is acceptable to include a separate status message explaining the resolved system theme, for example:
<p class="visually-hidden" aria-live="polite" id="theme-status">
System preference currently uses dark mode.
</p>Do NOT make this announcement excessively verbose. Avoid announcing the same state repeatedly when nothing has changed.
Do NOT use animated colour transitions by default. Theme changes can affect most of the viewport and may be uncomfortable or disorienting.
If a transition example is included, it MUST:
prefers-reduced-motionPrefer no theme-transition animation in the canonical example.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}The skill MUST continue requiring WCAG 2.2 Level AA contrast testing in every relevant state. Test at least:
Do NOT assume that colours which pass in light mode will pass in dark mode. Do NOT present experimental CSS functions as substitutes for actual contrast testing.
Include a complete example containing:
The example MUST be internally consistent. Use the same storage key, data attributes, class names, mode names, and selected-state mechanism throughout all code samples. Do NOT provide fragments that contradict one another.
<!DOCTYPE html>
<html lang="en" data-theme-mode="system" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Theme Selector Example</title>
<style>
/* Anti-flash: set theme as early as possible */
html[data-theme="dark"] {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
}
</style>
<script>
/* Early initialization — runs before first paint */
(function() {
var stored = null;
try { stored = localStorage.getItem('theme-mode'); } catch (e) {}
var mode = (stored === 'light' || stored === 'dark') ? stored : 'system';
var resolved = mode === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: mode;
document.documentElement.setAttribute('data-theme-mode', mode);
document.documentElement.setAttribute('data-theme', resolved);
})();
</script>
</head>
<body>
<header>
<nav aria-label="Main navigation">
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<div role="group" aria-label="Colour theme" id="theme-selector">
<button type="button" class="theme-mode-btn" aria-pressed="false" data-theme-value="system">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<path fill="none" stroke="currentColor" stroke-width="2" d="M3 4h18v12H3zM8 20h8"/>
</svg>
<span>System</span>
</button>
<button type="button" class="theme-mode-btn" aria-pressed="false" data-theme-value="light">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<circle cx="12" cy="12" r="5" fill="currentColor"/>
</svg>
<span>Light</span>
</button>
<button type="button" class="theme-mode-btn" aria-pressed="true" data-theme-value="dark">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
<span>Dark</span>
</button>
</div>
</header>
<main>
<h1>Theme Selector Example</h1>
<p>This page demonstrates a three-option theme selector with System, Light, and Dark modes.</p>
</main>
<p class="visually-hidden" aria-live="polite" id="theme-status"></p>
<script>
/* Main theme logic — runs after DOM ready */
(function() {
var STORAGE_KEY = 'theme-mode';
var VALID_MODES = ['system', 'light', 'dark'];
var buttons = document.querySelectorAll('.theme-mode-btn');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
var statusEl = document.getElementById('theme-status');
var currentMode = 'system';
function getStoredMode() {
try {
var stored = localStorage.getItem(STORAGE_KEY);
return VALID_MODES.indexOf(stored) !== -1 ? stored : 'system';
} catch (e) {
return 'system';
}
}
function setStoredMode(mode) {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch (e) {
/* Storage unavailable; preference still works for current session. */
}
}
function resolveTheme(mode) {
if (mode === 'system') {
return prefersDark.matches ? 'dark' : 'light';
}
return mode;
}
function updateButtons(activeMode) {
for (var i = 0; i < buttons.length; i++) {
var btn = buttons[i];
var isActive = btn.getAttribute('data-theme-value') === activeMode;
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
}
}
function applyMode(mode) {
currentMode = mode;
var resolved = resolveTheme(mode);
document.documentElement.setAttribute('data-theme-mode', mode);
document.documentElement.setAttribute('data-theme', resolved);
updateButtons(mode);
}
function handleActivation(event) {
var btn = event.currentTarget;
var mode = btn.getAttribute('data-theme-value');
setStoredMode(mode);
applyMode(mode);
}
/* Attach click handlers — no keyboard handlers needed */
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', handleActivation);
}
/* Listen for OS preference changes */
prefersDark.addEventListener('change', function() {
if (currentMode === 'system') {
applyMode('system');
}
});
/* Initialize */
applyMode(getStoredMode());
})();
</script>
</body>
</html>The following patterns MUST be rejected:
A single button that alternates only between light and dark and provides no way to return to System.
JavaScript that changes the theme in a focus or arrow-key handler.
A radiogroup where arrow keys both move focus and immediately change the entire page theme.
Storing dark when the user actually selected system and the system happened to be dark.
Showing the selected theme only through a different background colour.
Using localStorage.getItem() or setItem() without exception handling.
Waiting for DOMContentLoaded before applying the stored theme, causing an avoidable flash of the wrong theme.
Changing the selected option's accessible name instead of keeping its label stable and exposing selection with aria-pressed.
| Level | Meaning |
|---|---|
| Critical | Colour theme makes content or interaction completely inaccessible |
| Serious | Contrast or mode failure significantly impairs access for a disability group |
| Moderate | Theme degrades usability but content remains partially accessible |
| Minor | Best-practice gap; marginal impact |
Before shipping, verify all of the following:
Test or simulate localStorage throwing an exception. Confirm that:
Test with at least:
Confirm that assistive technology announces:
Record or observe keyboard traversal through the selector. Confirm that no colour scheme is previewed while tabbing.
Inspect the repository's existing testing conventions and add tests where the project supports them. Tests SHOULD verify, where practical:
aria-pressed="true"prefers-color-schemeDo NOT introduce a new test framework unless necessary. Follow the repository's existing approach.
color-scheme: light dark; declared on :rootlight-dark() with prefers-color-scheme only as fallbackoutline, not box-shadow alone<button type="button"> with aria-pressedsystem mode, theme resolves from prefers-color-scheme and auto-updateslocalStorage access wrapped in try/catchlocalStorage is availableprefers-reduced-motion respected for theme transitionscurrentColorcolor-mix() and contrast-color() gated behind @supports with explicit fallback valuesStandards horizon: WCAG 3.0's proposed APCA (Advanced Perceptual Contrast Algorithm) replaces the current 4.5:1 / 3:1 luminance-ratio model with a perceptual contrast model that treats light-on-dark and dark-on-light differently. This will directly affect light/dark mode contrast targets. Do not apply APCA to production work until WCAG 3.0 is a published standard. Monitor: <https://www.w3.org/TR/wcag-3.0/> and <https://git.apcacontrast.com/>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.