mcpField
L
light-dark-mode

light-dark-mode

github.com/mgifford设计与 UI57160 次安装6 周前更新
代码仓库

关于 light-dark-mode

用于在 HTML、CSS 和 JavaScript 中实现或审查明暗主题切换的无障碍设计技能。它规定三选项选择器、安全存储、对比度检查,并避免键盘移动焦点时改变主题。

主要功能

  1. 01System、Light、Dark 三选项选择器
  2. 02仅在明确激活后切换主题
  3. 03安全存储偏好并响应系统设置
  4. 04WCAG 对比度与强制颜色指南
  5. 05键盘操作与焦点状态要求

适用场景

  1. 01检查主题选择器的键盘可访问性
  2. 02实现主题偏好存储
  3. 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)")

当该偏好发生变化时,更新解析后的主题。不要在系统偏好发生变化时覆盖已存储的模式。


必需:初始渲染和防闪烁

提供一种尽早运行的初始化模式,在主样式表加载前或页面可见渲染前运行。该模式必须:

  1. 安全地读取已存储的偏好
  2. 验证已存储的值
  3. 默认使用系统模式
  4. 使用 prefers-color-scheme 解析系统模式
  5. 在首次重要绘制前设置根主题属性
  6. 设置适当的 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 函数作为实际对比度测试的替代方案。


必需:完整示例实现

包含一个完整示例,其中包含:

  1. 无障碍 HTML
  2. CSS
  3. 尽早执行的防闪烁初始化
  4. 主 JavaScript
  5. 安全的存储处理
  6. 系统偏好变更处理
  7. 强制颜色样式
  8. 可见焦点样式
  9. 已选状态样式

示例必须保持内部一致。所有代码示例中都必须使用相同的存储键、数据属性、类名、模式名称和已选状态机制。不要提供彼此矛盾的代码片段。

完整 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 呈现选中状态。


严重程度等级(本技能)

级别含义
严重颜色主题导致内容或交互完全无法访问
高对比度或模式失效,显著影响残障群体的访问
中等主题降低了可用性,但内容仍可部分访问
轻微未达到最佳实践;影响较小

验证与测试

发布前,验证以下所有内容:

键盘
  1. 加载页面。
  2. 按 Tab 键移至主题选择器。
  3. 继续按 Tab 键,依次浏览系统、浅色和深色选项。
  4. 确认焦点移动时页面主题不会更改。
  5. 在未选中的选项上按 Enter 键。
  6. 确认主题只更改一次。
  7. 在另一个选项上按空格键。
  8. 确认主题只更改一次。
  9. 确认焦点始终可见。
  10. 确认选中状态与焦点状态可以区分。
系统模式
  1. 选择系统。
  2. 确认系统模式已存储。
  3. 更改操作系统的颜色偏好。
  4. 确认页面无需重新加载即可更新。
  5. 确认存储的值仍为系统模式。
持久化
  1. 选择浅色。
  2. 重新加载页面。
  3. 确认仍选中浅色。
  4. 选择深色。
  5. 重新加载页面。
  6. 确认仍选中深色。
  7. 选择系统。
  8. 重新加载页面。
  9. 确认仍选中系统,且系统解析正确。
存储失败

测试或模拟 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)

参考资料

标准展望: 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/

评论

支持 Markdown