Loading...
Loading...
Load this skill whenever the project supports light/dark mode, colour theme switching, high-contrast mode, or responds to prefers-color-scheme. Under no circumstances hard-code colours that break in alternative themes. Absolutely always test colour contrast in both light and dark themes, and respect user OS-level colour preferences via CSS media queries.
npx skill4agent add mgifford/accessibility-skills light-dark-modeCanonical source:inexamples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.mdThis skill is derived from that file. When in doubt, the example is authoritative.mgifford/ACCESSIBILITY.md
<button type="button">aria-pressed="true"aria-pressed="false"role="radio"<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>systemsystemdarktheme-modesystemlightdarklocalStoragetry/catchselectedMode = "system" | "light" | "dark";
resolvedTheme = "light" | "dark";<html data-theme-mode="system" data-theme="dark">data-theme-modedata-themewindow.matchMedia("(prefers-color-scheme: dark)")prefers-color-schemecolor-scheme<head><meta name="color-scheme" content="light dark">: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;
}aria-pressed.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);
}:focus-visible@media (forced-colors: active)currentColorforced-color-adjust: none@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;
}
}<svg aria-hidden="true" focusable="false">...</svg>aria-pressed<p class="visually-hidden" aria-live="polite" id="theme-status">
System preference currently uses dark mode.
</p>prefers-reduced-motion@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}<!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>darksystemlocalStorage.getItem()setItem()DOMContentLoadedaria-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 |
localStoragearia-pressed="true"prefers-color-schemecolor-scheme: light dark;:rootlight-dark()prefers-color-schemeoutlinebox-shadow<button type="button">aria-pressedsystemprefers-color-schemelocalStoragetry/catchlocalStorageprefers-reduced-motioncurrentColorcolor-mix()contrast-color()@supportsStandards 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/