light-dark-mode

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Light/Dark Mode Accessibility Skill

明暗模式无障碍技能

Canonical source:
examples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.md
in
mgifford/ACCESSIBILITY.md
This 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.

标准来源
mgifford/ACCESSIBILITY.md
中的
examples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.md
本技能源自该文件。如有疑问,以示例内容为准。
在HTML、CSS或JavaScript中实现或审核色彩主题支持时,请遵循以下规则。 仅当项目支持明暗模式或用户主题切换时,才启用此技能。

Critical Rule: Focus Is Navigation, Not Selection

关键规则:焦点用于导航,而非选择

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.

主题必须在用户明确激活选项后才会更改。
通过键盘移动焦点或指针悬停在主题选项上时,绝对不能预览、选择或应用主题。在“系统”“浅色”“深色”选项间切换Tab键时,页面颜色必须保持不变,直到用户按下Enter或Space键。
这是一项关键要求。违反此要求会导致键盘导航时出现颜色闪烁,使患有前庭障碍、认知障碍和低视力的用户感到困惑。

Core Mandate

核心要求

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.
所有色彩主题在明暗两种模式下,包括强制色彩/高对比度模式,都必须满足WCAG 2.2 AA级对比度要求。请测试所有三种模式——而不仅仅是默认模式。

Required: Three-Option Theme Selector Pattern

必备:三选项主题选择器模式

Use a visible three-option selector that shows all three options at the same time:
  • System
  • Light
  • Dark
使用可见的三选项选择器,同时显示所有三个选项:
  • 系统
  • 浅色
  • 深色

Interaction pattern requirements

交互模式要求

  • MUST use a labelled group containing three native
    <button type="button">
    elements
  • MUST use
    aria-pressed="true"
    for the selected option and
    aria-pressed="false"
    for others
  • MUST NOT use a single cycling button
  • MUST NOT use a two-state light/dark toggle
  • MUST NOT use a menu that hides the available options
  • MUST NOT use custom
    role="radio"
    elements
  • MUST NOT use a radiogroup that changes theme when arrow-key focus moves
  • MUST NOT use hover or focus previews
  • MUST NOT automatically select when a user merely tabs to an option
  • 必须使用包含三个原生
    <button type="button">
    元素的带标签组
  • 选中的选项必须使用
    aria-pressed="true"
    ,其他选项使用
    aria-pressed="false"
  • 不得使用单一循环按钮
  • 不得使用两态明暗切换开关
  • 不得使用隐藏可用选项的菜单
  • 不得使用自定义
    role="radio"
    元素
  • 不得使用箭头键移动焦点时就更改主题的单选组
  • 不得使用悬停或焦点预览
  • 用户仅切换到某个选项时,不得自动选择该选项

Keyboard requirements

键盘操作要求

The selector MUST work with standard native button behaviour. Users MUST be able to:
  • Tab to each option
  • Use Shift+Tab to move backwards
  • Activate an option with Enter
  • Activate an option with Space
  • Activate an option with mouse, touch, switch device, voice control, or other pointer
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.
选择器必须支持标准原生按钮行为。用户必须能够:
  • 通过Tab键切换到每个选项
  • 使用Shift+Tab键向后移动
  • 使用Enter键激活选项
  • 使用Space键激活选项
  • 使用鼠标、触摸、切换设备、语音控制或其他指针设备激活选项
在选项间移动焦点时不得更改主题。主题必须仅在用户刻意激活后才更改。
在原生按钮行为已提供所需功能的情况下,请勿添加不必要的自定义键盘处理程序。

Prevent colour flashing during keyboard navigation

防止键盘导航时出现颜色闪烁

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.

主题必须仅在用户明确激活后才更改。通过键盘移动焦点或指针悬停在主题选项上时,绝对不能预览、选择或应用主题。
在“系统”“浅色”“深色”选项间切换Tab键时,页面颜色必须保持不变,直到用户按下Enter或Space键。

Required: HTML Structure

必备:HTML结构

Use a group with an accessible label containing three native buttons:
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>

使用带有无障碍标签的组,包含三个原生按钮:
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>

Required: Default Behaviour

必备:默认行为

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:
system
Do NOT store:
dark
This distinction is required so that later operating-system changes continue to affect the page.

当用户未做出明确选择时,将
system
作为默认偏好设置。
请勿将系统偏好转换为存储的浅色或深色偏好。
例如,当用户选择系统模式,而其操作系统当前为深色模式时,存储的值应为:
system
请勿存储:
dark
此区分是必要的,以便后续操作系统的更改仍能影响页面显示。

Required: Storage Requirements

必备:存储要求

Use a clearly named storage key such as
theme-mode
. Valid stored values are only:
system
,
light
,
dark
.
  • Validate stored values before using them
  • Wrap all
    localStorage
    reads and writes in
    try/catch
  • The theme control MUST continue to work for the current page when storage is unavailable
  • Do NOT allow a storage failure to stop script execution
Storage can be unavailable in:
  • Privacy-restricted browsers
  • Sandboxed contexts
  • Private browsing modes
  • Environments where storage access throws an exception

使用命名清晰的存储键,例如
theme-mode
。有效的存储值仅为:
system
light
dark
  • 使用存储值前先验证其有效性
  • 将所有
    localStorage
    的读写操作包裹在
    try/catch
  • 当存储不可用时,主题控件仍必须能在当前页面正常工作
  • 请勿因存储失败而停止脚本执行
存储不可用的场景包括:
  • 隐私受限的浏览器
  • 沙箱环境
  • 隐私浏览模式
  • 存储访问会抛出异常的环境

Required: Theme Application Model

必备:主题应用模型

Maintain separate values for the selected mode and resolved theme:
javascript
selectedMode = "system" | "light" | "dark";
resolvedTheme = "light" | "dark";
Apply both to the root element using explicit attributes:
html
<html data-theme-mode="system" data-theme="dark">
  • data-theme-mode
    represents the user's actual selection
  • data-theme
    represents the currently rendered light or dark scheme
When system is active, listen for changes to:
javascript
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.

分别维护所选模式和解析后的主题两个值:
javascript
selectedMode = "system" | "light" | "dark";
resolvedTheme = "light" | "dark";
通过显式属性将两者应用到根元素:
html
<html data-theme-mode="system" data-theme="dark">
  • data-theme-mode
    表示用户的实际选择
  • data-theme
    表示当前渲染的浅色或深色方案
当系统模式激活时,监听以下对象的变化:
javascript
window.matchMedia("(prefers-color-scheme: dark)")
当该偏好设置更改时,更新解析后的主题。系统偏好更改时,请勿覆盖存储的模式。

Required: Initial Rendering and Flash Prevention

必备:初始渲染与防闪烁

Provide an early initialization pattern that runs before the primary stylesheet or before the page is visibly rendered. The pattern MUST:
  1. Safely read the stored preference
  2. Validate the stored value
  3. Default to system
  4. Resolve system using
    prefers-color-scheme
  5. Set the root theme attributes before the first significant paint
  6. Set an appropriate
    color-scheme
    value
Include early in
<head>
:
html
<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.

提供一种提前初始化的模式,使其在主样式表加载前或页面可见渲染前运行。该模式必须:
  1. 安全读取存储的偏好设置
  2. 验证存储值
  3. 默认使用系统模式
  4. 使用
    prefers-color-scheme
    解析系统模式
  5. 在首次重要绘制前设置根主题属性
  6. 设置合适的
    color-scheme
<head>
中尽早添加:
html
<meta name="color-scheme" content="light dark">
说明此早期脚本旨在减少页面加载时出现的错误配色方案闪烁。请勿声称脚本可以在所有浏览器中消除所有可能的视觉过渡。应将其描述为减少或防止因偏好设置应用过晚而导致的常见闪烁问题。

Required: CSS Custom Properties Pattern

必备:CSS自定义属性模式

Use semantic theme tokens through CSS custom properties. Do NOT hard-code component colours independently throughout the example.
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;
}
Component styles MUST read from the tokens above rather than hardcode colour values.

通过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);
  }
}

/* 手动覆盖会在用户选择特定方案时缩小生效范围。 */
[data-theme="light"] {
  color-scheme: light;
}
[data-theme="dark"] {
  color-scheme: dark;
}
组件样式必须从上述令牌中读取颜色值,而非硬编码颜色。

Required: Selected and Focus State Styling

必备:选中与焦点状态样式

The selected state MUST NOT be communicated through colour alone. Use multiple cues such as:
  • aria-pressed
  • A visible checkmark or icon
  • A thicker or otherwise distinct border
  • Font-weight
  • Text labels
Focus and selected state MUST be visually distinguishable from one another. A focused but unselected option MUST NOT look identical to the selected option.
css
.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.

选中状态不得仅通过颜色来传达。请使用多种提示,例如:
  • aria-pressed
    属性
  • 可见的勾选标记或图标
  • 更粗或其他独特的边框
  • 字体粗细
  • 文本标签
焦点状态和选中状态必须在视觉上可区分。已聚焦但未选中的选项不得与选中选项外观相同。
css
.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;
}

/* 焦点指示器 — 使用可见轮廓,而非box-shadow */
.theme-mode-btn:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

/* 选中状态 — 与焦点状态区分开 */
.theme-mode-btn[aria-pressed="true"] {
  border-color: var(--color-link);
  font-weight: 600;
  background: var(--color-background);
}
使用
:focus-visible
并搭配真实的轮廓。除非用同等或更明显的可见焦点指示器替代,否则请勿移除轮廓。

Required: Forced-Colours Support

必备:强制色彩模式支持

The selector MUST remain usable in Windows High Contrast Mode and other forced-colours environments. Do NOT rely only on:
  • Background colours
  • Gradients
  • Box shadows
  • Opacity
  • SVG fill colours
  • Colour differences
Use system colours where appropriate within
@media (forced-colors: active)
.
Ensure that:
  • Button boundaries remain visible
  • The selected option remains identifiable
  • The focused option remains identifiable
  • Selected and focused states remain distinct
  • Icons using
    currentColor
    remain visible
Do NOT use
forced-color-adjust: none
unless there is a specific, documented reason and the resulting colours are tested.
css
@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;
  }
}

选择器必须在Windows高对比度模式和其他强制色彩环境中保持可用。请勿仅依赖:
  • 背景色
  • 渐变
  • 盒阴影
  • 透明度
  • SVG填充色
  • 颜色差异
@media (forced-colors: active)
中,酌情使用系统颜色。
确保:
  • 按钮边界保持可见
  • 选中选项可识别
  • 聚焦选项可识别
  • 选中状态和聚焦状态保持区分
  • 使用
    currentColor
    的图标保持可见
除非有特定的文档化理由,且结果颜色经过测试,否则请勿使用
forced-color-adjust: none
css
@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;
  }
}

Required: Accessible Naming and Status

必备:无障碍命名与状态

Use visible text labels: System, Light, Dark. Icons MAY supplement the labels but MUST NOT replace them.
Decorative icons MUST use:
html
<svg aria-hidden="true" focusable="false">...</svg>
Do NOT dynamically replace stable button labels with phrases such as:
  • Switch to dark
  • Current dark mode
  • Use system dark
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:
html
<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.

使用可见文本标签:系统、浅色、深色。图标可作为标签的补充,但不得替代标签。
装饰性图标必须使用:
html
<svg aria-hidden="true" focusable="false">...</svg>
请勿将稳定的按钮标签动态替换为以下短语:
  • 切换到深色模式
  • 当前为深色模式
  • 使用系统深色模式
按钮标签应描述该按钮选择的偏好设置。选中状态通过
aria-pressed
属性传达。
可以添加单独的状态消息来解释解析后的系统主题,例如:
html
<p class="visually-hidden" aria-live="polite" id="theme-status">
  系统偏好当前使用深色模式。
</p>
请勿使此提示过于冗长。当状态未发生变化时,避免重复提示相同内容。

Required: Reduced Motion

必备:减少动画

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:
  • Be subtle
  • Avoid transitioning every CSS property
  • Respect
    prefers-reduced-motion
  • Not delay the actual state change
  • Not create intermediate low-contrast states
Prefer no theme-transition animation in the canonical example.
css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}

默认情况下请勿使用颜色过渡动画。主题更改会影响大部分视口,可能会让用户感到不适或困惑。
如果包含过渡示例,必须:
  • 效果柔和
  • 避免过渡所有CSS属性
  • 尊重
    prefers-reduced-motion
    设置
  • 不延迟实际状态更改
  • 不创建中间低对比度状态
在标准示例中,建议不使用主题过渡动画。
css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}

Required: Contrast Requirements

必备:对比度要求

The skill MUST continue requiring WCAG 2.2 Level AA contrast testing in every relevant state. Test at least:
  • System resolving to light
  • System resolving to dark
  • Explicit Light
  • Explicit Dark
  • Forced colours
  • Keyboard focus
  • Selected option
  • Hover
  • Disabled controls (if any)
  • Links
  • Form fields
  • Borders and graphical objects
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.

本技能必须继续要求在所有相关状态下进行WCAG 2.2 AA级对比度测试。至少测试:
  • 系统模式解析为浅色
  • 系统模式解析为深色
  • 显式浅色模式
  • 显式深色模式
  • 强制色彩模式
  • 键盘焦点状态
  • 选中选项
  • 悬停状态
  • 禁用控件(如有)
  • 链接
  • 表单字段
  • 边框和图形对象
请勿假设在浅色模式下通过对比度测试的颜色在深色模式下也能通过。请勿将实验性CSS函数作为实际对比度测试的替代方案。

Required: Complete Example Implementation

必备:完整示例实现

Include a complete example containing:
  1. Accessible HTML
  2. CSS
  3. Early anti-flash initialization
  4. Main JavaScript
  5. Safe storage handling
  6. System-preference change handling
  7. Forced-colours styles
  8. Visible focus styles
  9. Selected-state styles
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.
包含完整示例,包括:
  1. 无障碍HTML
  2. CSS样式
  3. 提前防闪烁初始化
  4. 核心JavaScript逻辑
  5. 安全存储处理
  6. 系统偏好更改处理
  7. 强制色彩模式样式
  8. 可见焦点样式
  9. 选中状态样式
示例必须内部一致。在所有代码示例中使用相同的存储键、数据属性、类名、模式名称和选中状态机制。请勿提供相互矛盾的代码片段。

Complete HTML

完整HTML

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>

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>
    /* 防闪烁:尽早设置主题 */
    html[data-theme="dark"] {
      color-scheme: dark;
    }
    html[data-theme="light"] {
      color-scheme: light;
    }
  </style>
  <script>
    /* 提前初始化 — 在首次绘制前运行 */
    (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>
    /* 核心主题逻辑 — DOM加载完成后运行 */
    (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) {
          /* 存储不可用;偏好设置仍在当前会话生效。 */
        }
      }

      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);
      }

      /* 添加点击处理程序 — 无需键盘处理程序 */
      for (var i = 0; i < buttons.length; i++) {
        buttons[i].addEventListener('click', handleActivation);
      }

      /* 监听操作系统偏好更改 */
      prefersDark.addEventListener('change', function() {
        if (currentMode === 'system') {
          applyMode('system');
        }
      });

      /* 初始化 */
      applyMode(getStoredMode());
    })();
  </script>
</body>
</html>

Required: Failure Patterns

必备:禁用模式

The following patterns MUST be rejected:
以下模式必须被拒绝:

Binary toggle

二元切换开关

A single button that alternates only between light and dark and provides no way to return to System.
仅在浅色和深色之间切换的单一按钮,无法返回系统模式。

Focus-triggered preview

焦点触发预览

JavaScript that changes the theme in a focus or arrow-key handler.
在焦点或箭头键处理程序中更改主题的JavaScript代码。

Incorrect radio implementation

错误的单选框实现

A radiogroup where arrow keys both move focus and immediately change the entire page theme.
箭头键移动焦点时立即更改整个页面主题的单选组。

Incorrect persistence

错误的持久化方式

Storing
dark
when the user actually selected
system
and the system happened to be dark.
当用户实际选择了系统模式,而系统恰好为深色时,存储
dark
而非
system

Colour-only selection

仅通过颜色区分选中状态

Showing the selected theme only through a different background colour.
仅通过不同背景色显示选中主题。

Unsafe storage

不安全的存储方式

Using
localStorage.getItem()
or
setItem()
without exception handling.
未添加异常处理的
localStorage.getItem()
setItem()
调用。

Late initialization

延迟初始化

Waiting for
DOMContentLoaded
before applying the stored theme, causing an avoidable flash of the wrong theme.
等待
DOMContentLoaded
后再应用存储的主题,导致不必要的错误主题闪烁。

Dynamic label confusion

动态标签混淆

Changing the selected option's accessible name instead of keeping its label stable and exposing selection with
aria-pressed
.

更改选中选项的无障碍名称,而非保持标签稳定并通过
aria-pressed
暴露选中状态。

Severity Scale (this skill)

严重程度等级(本技能)

LevelMeaning
CriticalColour theme makes content or interaction completely inaccessible
SeriousContrast or mode failure significantly impairs access for a disability group
ModerateTheme degrades usability but content remains partially accessible
MinorBest-practice gap; marginal impact

等级含义
关键色彩主题导致内容或交互完全无法访问
严重对比度或模式故障严重影响残障群体的访问
中等主题降低可用性,但内容仍部分可访问
轻微最佳实践缺失;影响极小

Validation and Testing

验证与测试

Before shipping, verify all of the following:
发布前,请验证以下所有内容:

Keyboard

键盘操作

  1. Load the page.
  2. Tab to the theme selector.
  3. Continue tabbing through System, Light, and Dark.
  4. Confirm that the page theme does NOT change as focus moves.
  5. Press Enter on an unselected option.
  6. Confirm that the theme changes once.
  7. Press Space on another option.
  8. Confirm that the theme changes once.
  9. Confirm that focus remains visible.
  10. Confirm that the selected state and focus state are distinguishable.
  1. 加载页面。
  2. 通过Tab键切换到主题选择器。
  3. 继续通过Tab键在系统、浅色、深色选项间切换。
  4. 确认页面主题不会随焦点移动而更改。
  5. 在未选中的选项上按Enter键。
  6. 确认主题仅更改一次。
  7. 在另一个选项上按Space键。
  8. 确认主题仅更改一次。
  9. 确认焦点保持可见。
  10. 确认选中状态和焦点状态可区分。

System mode

系统模式

  1. Select System.
  2. Confirm that system is stored.
  3. Change the operating-system colour preference.
  4. Confirm that the page updates without reloading.
  5. Confirm that the stored value remains system.
  1. 选择系统模式。
  2. 确认系统模式已存储。
  3. 更改操作系统的颜色偏好。
  4. 确认页面无需刷新即可更新。
  5. 确认存储的值仍为系统模式。

Persistence

持久化

  1. Select Light.
  2. Reload.
  3. Confirm that Light remains selected.
  4. Select Dark.
  5. Reload.
  6. Confirm that Dark remains selected.
  7. Select System.
  8. Reload.
  9. Confirm that System remains selected and resolves correctly.
  1. 选择浅色模式。
  2. 刷新页面。
  3. 确认浅色模式仍保持选中。
  4. 选择深色模式。
  5. 刷新页面。
  6. 确认深色模式仍保持选中。
  7. 选择系统模式。
  8. 刷新页面。
  9. 确认系统模式仍保持选中并正确解析。

Storage failure

存储失败

Test or simulate
localStorage
throwing an exception. Confirm that:
  • The page still renders
  • The selector still works
  • The selected preference works for the current page
  • No uncaught JavaScript error stops execution
测试或模拟
localStorage
抛出异常。确认:
  • 页面仍能正常渲染
  • 选择器仍能正常工作
  • 选中的偏好设置在当前页面仍生效
  • 未捕获的JavaScript错误不会停止执行

Assistive technology

辅助技术

Test with at least:
  • One desktop screen reader
  • Keyboard only
  • Browser zoom at 200% and 400%
  • Forced-colours or Windows High Contrast Mode
  • Touch or mobile interaction where practical
Confirm that assistive technology announces:
  • The group label
  • Each button label
  • Pressed or not pressed state
至少使用以下工具测试:
  • 一款桌面屏幕阅读器
  • 仅键盘操作
  • 浏览器缩放至200%和400%
  • 强制色彩模式或Windows高对比度模式
  • 触摸或移动设备交互(如适用)
确认辅助技术会提示:
  • 组标签
  • 每个按钮的标签
  • 已按下或未按下状态

Visual stability

视觉稳定性

Record or observe keyboard traversal through the selector. Confirm that no colour scheme is previewed while tabbing.
录制或观察通过选择器的键盘遍历过程。确认切换Tab键时不会预览配色方案。

Automated tests

自动化测试

Inspect the repository's existing testing conventions and add tests where the project supports them. Tests SHOULD verify, where practical:
  • All three options exist
  • All options are native buttons
  • Only one option has
    aria-pressed="true"
  • Focus alone does not change the theme
  • Click activation changes the theme
  • Keyboard activation changes the theme
  • System tracks
    prefers-color-scheme
  • An explicit Light or Dark selection ignores later system changes
  • Invalid stored values fall back to System
  • Storage exceptions do not crash the control
  • Selected mode and resolved theme use separate root attributes
Do NOT introduce a new test framework unless necessary. Follow the repository's existing approach.

检查仓库现有的测试约定,并在项目支持的情况下添加测试。测试应尽可能验证:
  • 三个选项均存在
  • 所有选项均为原生按钮
  • 仅有一个选项的
    aria-pressed="true"
  • 仅焦点移动不会更改主题
  • 点击激活会更改主题
  • 键盘激活会更改主题
  • 系统模式跟踪
    prefers-color-scheme
  • 显式选择浅色或深色模式后,后续系统更改不会生效
  • 无效存储值会回退到系统模式
  • 存储异常不会导致控件崩溃
  • 所选模式和解析后的主题使用单独的根属性
除非必要,否则请勿引入新的测试框架。遵循仓库现有的测试方法。

Definition of Done Checklist

完成标准检查清单

  • All text/UI elements meet WCAG 2.2 AA contrast in light mode
  • All text/UI elements meet WCAG 2.2 AA contrast in dark mode
  • color-scheme: light dark;
    declared on
    :root
  • Theme tokens use
    light-dark()
    with
    prefers-color-scheme
    only as fallback
  • Forced-colours mode: content comprehensible; no meaning conveyed by shadow, gradient, or background alone
  • Focus rings use
    outline
    , not
    box-shadow
    alone
  • Information not conveyed by colour alone — icon + text + colour
  • Focus indicators visible and meeting 3:1 in all modes
  • Three-option selector with System, Light, and Dark visible simultaneously
  • Selector uses native
    <button type="button">
    with
    aria-pressed
  • Theme changes only on explicit activation (Enter, Space, click)
  • Focus alone does NOT change the theme
  • In
    system
    mode, theme resolves from
    prefers-color-scheme
    and auto-updates
  • localStorage
    access wrapped in
    try/catch
  • Invalid stored values fall back to system
  • User preference persists across sessions where
    localStorage
    is available
  • prefers-reduced-motion
    respected for theme transitions
  • SVGs use
    currentColor
  • color-mix()
    and
    contrast-color()
    gated behind
    @supports
    with explicit fallback values
  • Browser support, forced-colours, high contrast, light mode, dark mode, and manual WCAG contrast are verified
  • Keyboard user can tab across all options without changing page colours

  • 所有文本/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对比度
  • 同时显示系统、浅色、深色三个选项的选择器
  • 选择器使用带有
    aria-pressed
    的原生
    <button type="button">
  • 仅在明确激活(Enter、Space、点击)时更改主题
  • 仅焦点移动不会更改主题
  • 在系统模式下,主题从
    prefers-color-scheme
    解析并自动更新
  • localStorage
    访问包裹在
    try/catch
  • 无效存储值回退到系统模式
  • localStorage
    可用时,用户偏好可跨会话持久化
  • 主题过渡尊重
    prefers-reduced-motion
    设置
  • SVG使用
    currentColor
  • color-mix()
    contrast-color()
    @supports
    中使用,并带有显式回退值
  • 已验证浏览器支持、强制色彩模式、高对比度、浅色模式、深色模式和手动WCAG对比度
  • 键盘用户可在所有选项间切换Tab键而不更改页面颜色

Key WCAG Criteria

关键WCAG标准

  • 1.4.1 Use of Color (A)
  • 1.4.3 Contrast Minimum (AA) — Serious if failing in either mode
  • 1.4.11 Non-text Contrast (AA)
  • 2.4.11 Focus Appearance (AA, WCAG 2.2)

  • 1.4.1 颜色使用(A级)
  • 1.4.3 最低对比度(AA级)—— 任一模式不满足则为严重问题
  • 1.4.11 非文本对比度(AA级)
  • 2.4.11 焦点外观(AA级,WCAG 2.2)

References

参考资料

Standards 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/
标准前沿: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/