关于 light-dark-mode
用于在 HTML、CSS 和 JavaScript 中实现或审查明暗主题切换的无障碍设计技能。它规定三选项选择器、安全存储、对比度检查,并避免键盘移动焦点时改变主题。
主要功能
- 01System、Light、Dark 三选项选择器
- 02仅在明确激活后切换主题
- 03安全存储偏好并响应系统设置
- 04WCAG 对比度与强制颜色指南
- 05键盘操作与焦点状态要求
适用场景
- 01检查主题选择器的键盘可访问性
- 02实现主题偏好存储
- 03测试明暗及强制颜色模式下的对比度
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mgifford/ accessibility-skills --skill light-dark-mode安装后在 Claude Code 中输入/light-dark-mode确认已加载。
附带文件SYNC.md
详细介绍
浅色/深色模式无障碍技能
规范来源:
mgifford/ACCESSIBILITY.md中的examples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.md此技能源自该文件。如有疑问,以示例为准。
在 HTML、CSS 或 JavaScript 中实现或审查颜色主题支持时,均应遵循这些规则。 仅当项目支持浅色/深色模式或用户主题切换时,才加载此技能。
关键规则:焦点用于导航,而非选择
只有在用户明确激活某个选项后,主题才能更改。
在主题选项之间移动键盘焦点或将指针悬停在选项上,绝不能预览、选择或应用主题。在 System、Light 和 Dark 之间按 Tab 键时,必须保持页面颜色不变,直到用户按下 Enter 或 Space。
这是 关键要求。违反此要求会导致用户在键盘导航时看到颜色闪烁,并使患有前庭障碍、认知障碍和低视力的用户感到迷失。
核心要求
所有颜色主题在浅色和深色模式下都必须符合 WCAG 2.2 AA 级对比度要求,包括强制颜色/高对比度模式。三种模式都要测试,而不仅仅是默认模式。
必须:三选项主题选择器模式
使用一个可见的三选项选择器,同时显示全部三个选项:
- System
- Light
- Dark
交互模式要求
- 必须使用包含三个原生
<button type="button">元素且带有标签的分组 - 必须为已选选项使用
aria-pressed="true",其他选项使用aria-pressed="false" - 绝不能使用单个循环切换按钮
- 绝不能使用两状态的浅色/深色切换按钮
- 绝不能使用会隐藏可用选项的菜单
- 绝不能使用自定义
role="radio"元素 - 绝不能使用在方向键移动焦点时更改主题的 radiogroup
- 绝不能使用悬停或焦点预览
- 绝不能在用户仅通过 Tab 键移至某个选项时自动选择该选项
键盘要求
选择器必须支持标准的原生按钮行为。用户必须能够:
- Tab 键移至每个选项
- 使用 Shift+Tab 向后移动
- 使用 Enter 激活选项
- 使用 Space 激活选项
- 使用鼠标、触摸、开关设备、语音控制或其他指针方式激活选项
在选项之间移动焦点绝不能更改主题。只有用户明确激活后,主题才能更改。
如果原生按钮行为已满足所需功能,请勿添加不必要的自定义键盘事件处理程序。
防止键盘导航时颜色闪烁
只有在用户明确激活后,主题才能更改。在主题选项之间移动键盘焦点或将指针悬停在选项上,绝不能预览、选择或应用主题。
在 System、Light 和 Dark 之间按 Tab 键时,必须保持页面颜色不变,直到用户按下 Enter 或 Space。
必须:HTML 结构
使用一个包含三个原生按钮且带有无障碍标签的分组:
<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>
必须:默认行为
如果用户尚未明确选择,则使用 system 作为默认偏好。
绝不能将系统偏好转换为已存储的浅色或深色偏好。
例如,当用户选择 system,且其操作系统当前处于深色模式时,存储:
system
绝不能存储:
dark
此区别是必需的,这样之后操作系统的更改才能继续影响页面。
必须:存储要求
使用名称清晰的存储键,例如 theme-mode。有效的存储值仅限于:system、light、dark。
- 使用存储值前先进行验证
- 所有
localStorage读取和写入操作都必须放在try/catch中 - 存储不可用时,主题控件仍必须能够控制当前页面
- 绝不能让存储失败导致脚本停止执行
以下情况可能无法使用存储:
- 隐私受限的浏览器
- 沙盒上下文
- 私密浏览模式
- 访问存储时会引发异常的环境
必需:主题应用模型
为所选模式和解析后的主题分别维护独立值:
selectedMode = "system" | "light" | "dark";
resolvedTheme = "light" | "dark";
使用明确的属性将两者应用到根元素:
<html data-theme-mode="system" data-theme="dark">
data-theme-mode表示用户实际选择的模式data-theme表示当前呈现的浅色或深色方案
系统模式处于启用状态时,监听以下项的变化:
window.matchMedia("(prefers-color-scheme: dark)")
当该偏好发生变化时,更新解析后的主题。不要在系统偏好发生变化时覆盖已存储的模式。
必需:初始渲染和防闪烁
提供一种尽早运行的初始化模式,在主样式表加载前或页面可见渲染前运行。该模式必须:
- 安全地读取已存储的偏好
- 验证已存储的值
- 默认使用系统模式
- 使用
prefers-color-scheme解析系统模式 - 在首次重要绘制前设置根主题属性
- 设置适当的
color-scheme值
在 <head> 中尽早加入:
<meta name="color-scheme" content="light dark">
说明此早期脚本旨在减少页面加载时错误配色方案闪现的情况。不要声称脚本能消除所有浏览器中可能出现的每一种视觉过渡。应说明它能减少或防止因应用偏好过晚而导致的常见闪烁。
必需:CSS 自定义属性模式
通过 CSS 自定义属性使用语义化主题令牌。不要在示例中各处独立硬编码组件颜色。
: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。除非以同等或更醒目的可见焦点指示器替代,否则不要移除轮廓。
必需:强制颜色支持
选择器必须在 Windows 高对比度模式及其他强制颜色环境中保持可用。不要仅依赖以下内容:
- 背景颜色
- 渐变
- 盒阴影
- 不透明度
- SVG 填充颜色
- 颜色差异
在适当情况下,在 @media (forced-colors: active) 中使用系统颜色。
确保:
- 按钮边界仍然清晰可见
- 仍然可以识别已选中的选项
- 仍然可以识别处于焦点的选项
- 选中状态和焦点状态仍然有所区别
- 使用
currentColor的图标仍然可见
除非有具体且有记录的理由,并且已测试最终颜色,否则不要使用 forced-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>
不要让此公告过于冗长。当状态未发生变化时,避免重复公告相同状态。
必需:减少动态效果
默认不要使用颜色过渡动画。主题变化可能影响视口的大部分区域,造成不适或使人迷失方向。
如果包含过渡示例,则必须:
- 效果微妙
- 避免对每个 CSS 属性都应用过渡
- 遵循
prefers-reduced-motion - 不延迟实际状态变化
- 不产生中间的低对比度状态
典型示例中最好不使用主题过渡动画。
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
必需:对比度要求
该技能必须继续要求在每种相关状态下进行 WCAG 2.2 AA 级对比度测试。至少测试:
- 系统解析为浅色
- 系统解析为深色
- 显式浅色
- 显式深色
- 强制颜色
- 键盘焦点
- 已选选项
- 悬停
- 禁用控件(如有)
- 链接
- 表单字段
- 边框和图形对象
不要假设在浅色模式下通过测试的颜色在深色模式下也能通过。不要将实验性 CSS 函数作为实际对比度测试的替代方案。
必需:完整示例实现
包含一个完整示例,其中包含:
- 无障碍 HTML
- CSS
- 尽早执行的防闪烁初始化
- 主 JavaScript
- 安全的存储处理
- 系统偏好变更处理
- 强制颜色样式
- 可见焦点样式
- 已选状态样式
示例必须保持内部一致。所有代码示例中都必须使用相同的存储键、数据属性、类名、模式名称和已选状态机制。不要提供彼此矛盾的代码片段。
完整 HTML
<!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>
必须拒绝的失败模式
必须拒绝以下模式:
二元切换
单个按钮只能在浅色和深色之间切换,且没有返回系统模式的方式。
焦点触发预览
在焦点或方向键处理程序中更改主题的 JavaScript。
错误的单选按钮实现
一个单选组中,方向键既移动焦点,又立即更改整个页面主题。
错误的持久化
用户实际选择了 system,且系统恰好处于深色模式时,却存储 dark。
仅通过颜色表示选中状态
仅通过不同的背景颜色显示选中的主题。
不安全的存储
未进行异常处理就使用 localStorage.getItem() 或 setItem()。
延迟初始化
等待 DOMContentLoaded 后才应用存储的主题,导致本可避免的错误主题闪烁。
动态标签混淆
更改已选选项的无障碍名称,而不是保持其标签稳定并通过 aria-pressed 呈现选中状态。
严重程度等级(本技能)
| 级别 | 含义 |
|---|---|
| 严重 | 颜色主题导致内容或交互完全无法访问 |
| 高 | 对比度或模式失效,显著影响残障群体的访问 |
| 中等 | 主题降低了可用性,但内容仍可部分访问 |
| 轻微 | 未达到最佳实践;影响较小 |
验证与测试
发布前,验证以下所有内容:
键盘
- 加载页面。
- 按 Tab 键移至主题选择器。
- 继续按 Tab 键,依次浏览系统、浅色和深色选项。
- 确认焦点移动时页面主题不会更改。
- 在未选中的选项上按 Enter 键。
- 确认主题只更改一次。
- 在另一个选项上按空格键。
- 确认主题只更改一次。
- 确认焦点始终可见。
- 确认选中状态与焦点状态可以区分。
系统模式
- 选择系统。
- 确认系统模式已存储。
- 更改操作系统的颜色偏好。
- 确认页面无需重新加载即可更新。
- 确认存储的值仍为系统模式。
持久化
- 选择浅色。
- 重新加载页面。
- 确认仍选中浅色。
- 选择深色。
- 重新加载页面。
- 确认仍选中深色。
- 选择系统。
- 重新加载页面。
- 确认仍选中系统,且系统解析正确。
存储失败
测试或模拟 localStorage 抛出异常。确认:
- 页面仍可正常呈现
- 选择器仍可使用
- 当前页面仍采用所选偏好
- 没有未捕获的 JavaScript 错误会中止执行
辅助技术
至少使用以下方式进行测试:
- 一款桌面屏幕阅读器
- 仅使用键盘
- 浏览器缩放至 200% 和 400%
- 强制颜色模式或 Windows 高对比度模式
- 在可行的情况下,使用触控或移动设备进行交互
确认辅助技术会播报:
- 组标签
- 每个按钮的标签
- 按下或未按下状态
视觉稳定性
记录或观察通过键盘遍历选择器的过程。确认按 Tab 键时不会预览任何配色方案。
自动化测试
检查代码库现有的测试惯例,并在项目支持的情况下添加测试。在可行时,测试应验证:
- 三个选项均存在
- 所有选项都是原生按钮
- 只有一个选项具有
aria-pressed="true" - 仅聚焦不会更改主题
- 点击激活会更改主题
- 使用键盘激活会更改主题
- 系统会跟踪
prefers-color-scheme - 显式选择 Light 或 Dark 后,忽略之后的系统更改
- 无效的存储值会回退到 System
- 存储操作异常不会导致控件崩溃
- 所选模式和解析出的主题使用不同的根属性
除非必要,否则不要引入新的测试框架。遵循代码库现有的做法。
完成标准检查清单
- 所有文本/UI 元素在浅色模式下均符合 WCAG 2.2 AA 对比度要求
- 所有文本/UI 元素在深色模式下均符合 WCAG 2.2 AA 对比度要求
- 在
:root上声明了color-scheme: light dark; - 主题令牌使用
light-dark(),仅将prefers-color-scheme用作回退 - 强制颜色模式:内容清晰易懂;不只依靠阴影、渐变或背景传达含义
- 焦点环使用
outline,不只使用box-shadow - 信息不只通过颜色传达 — 图标 + 文本 + 颜色
- 所有模式下的焦点指示器均清晰可见且达到 3:1
- 三选项选择器同时显示 System、Light 和 Dark
- 选择器使用带有
aria-pressed的原生<button type="button"> - 只有显式激活(Enter、Space、点击)时才会更改主题
- 仅聚焦绝不会更改主题
- 在
system模式下,主题根据prefers-color-scheme解析并自动更新 - 使用
try/catch包裹localStorage访问 - 无效的存储值会回退到 system
- 在
localStorage可用时,用户偏好会在会话间保留 - 主题过渡遵循
prefers-reduced-motion设置 - SVG 使用
currentColor -
color-mix()和contrast-color()置于@supports中,并提供明确的回退值 - 已验证浏览器支持、强制颜色模式、高对比度、浅色模式、深色模式和手动 WCAG 对比度
- 键盘用户可以遍历所有选项,而不会更改页面颜色
关键 WCAG 标准
- 1.4.1 颜色的使用(A)
- 1.4.3 最低对比度(AA)— 任一模式下未达标都属于严重问题
- 1.4.11 非文本对比度(AA)
- 2.4.11 焦点外观(AA,WCAG 2.2)
参考资料
- 完整的最佳实践指南
- 现代 CSS 主题架构 — 关于通过语义设计令牌将设计决策与实现分离的补充指南
- WCAG 2.2 理解 1.4.3 对比度最低要求
- WCAG 2.2 理解 2.4.11 焦点外观
- MDN:light-dark()
- MDN:contrast-color()
- CSS prefers-color-scheme(媒体查询第 5 级)
- CSS forced-colors(媒体查询第 5 级)
- CSS 系统颜色(CSS Color 第 4 级)
- MDN:forced-color-adjust
- Baseline 2023:color-mix() 浏览器支持情况
标准展望: WCAG 3.0 提议采用的 APCA(高级感知对比度算法)将用感知对比度模型取代当前的 4.5:1 / 3:1 亮度比模型,该模型会区别对待深色背景上的浅色和浅色背景上的深色。这将直接影响浅色/深色模式的对比度目标。 在 WCAG 3.0 成为正式发布的标准之前,请勿在生产环境中采用 APCA。 关注:https://www.w3.org/TR/wcag-3.0/ 和 https://git.apcacontrast.com/