普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月1日首页

构建无障碍组件之Radio group pattern

作者 anOnion
2026年2月28日 23:23

Radio Group Pattern 详解:构建无障碍单选按钮组件

单选按钮(Radio Button)是表单中用于从一组互斥选项中选择单个项目的控件。本文基于 W3C WAI-ARIA Radio Pattern 规范,详解如何构建无障碍的单选按钮组件。

一、Radio 的定义与核心概念

单选按钮允许用户从一组相关但互斥的选项中选择一个且仅一个选项。当用户选择一个选项时,同组中之前被选中的选项会自动取消选中。

1.1 核心特性

  • 互斥性:同一组内只能有一个选项被选中
  • 预设选中:通常有一个选项默认被选中
  • 分组依赖:通过相同的 name 属性(HTML)或 aria-label(ARIA)进行分组

1.2 与 Checkbox 的区别

特性 Radio Checkbox
选择数量 单选 可多选
互斥性 同组互斥 独立
默认状态 通常预设一个选中 可全部未选中
键盘导航 方向键切换 Tab 切换

二、WAI-ARIA 角色与属性

2.1 基本角色

单选按钮具有 role="radio"

2.2 状态属性

2.3 分组属性

单选按钮必须分组,以便辅助技术理解它们之间的关系:

<div
  role="radiogroup"
  aria-labelledby="group-label">
  <h3 id="group-label">选择支付方式</h3>
  <div
    role="radio"
    aria-checked="true"
    tabindex="0">
    信用卡
  </div>
  <div
    role="radio"
    aria-checked="false"
    tabindex="-1">
    支付宝
  </div>
  <div
    role="radio"
    aria-checked="false"
    tabindex="-1">
    微信支付
  </div>
</div>

2.4 可访问标签

每个单选按钮的可访问标签可以通过以下方式提供:

  • 可见文本内容:直接包含在具有 role="radio" 的元素内的文本
  • aria-labelledby:引用包含标签文本的元素的 ID
  • aria-label:直接在单选按钮元素上设置标签文本

2.5 描述属性

如果包含额外的描述性静态文本,使用 aria-describedby

<div
  role="radio"
  aria-checked="false"
  aria-describedby="option-desc">
  高级会员
</div>
<p id="option-desc">包含所有高级功能,每月 99 元</p>

三、键盘交互规范

3.1 基本键盘操作

当单选按钮获得焦点时:

按键 功能
Space 如果焦点在未选中的单选按钮上,选中该按钮(取消选中同组其他按钮)
Tab 将焦点移动到组内的选中单选按钮;如果组内没有选中按钮,将焦点移动到组内第一个单选按钮

3.2 方向键导航(可选但推荐)

按键 功能
Down Arrow / Right Arrow 将焦点移动到下一个单选按钮,并选中它;如果焦点在最后一个按钮上,将焦点移动到第一个按钮
Up Arrow / Left Arrow 将焦点移动到上一个单选按钮,并选中它;如果焦点在第一个按钮上,将焦点移动到最后一个按钮

四、实现方式

4.1 原生 HTML 实现(推荐)

原生 HTML <input type="radio"> 提供完整的无障碍支持:

<fieldset>
  <legend>选择性别</legend>
  <label>
    <input
      type="radio"
      name="gender"
      value="male"
      checked /></label>
  <label>
    <input
      type="radio"
      name="gender"
      value="female" /></label>
  <label>
    <input
      type="radio"
      name="gender"
      value="other" />
    其他
  </label>
</fieldset>

4.2 ARIA 实现(自定义样式)

<div
  role="radiogroup"
  aria-labelledby="payment-label">
  <h3 id="payment-label">选择支付方式</h3>

  <div
    role="radio"
    aria-checked="true"
    tabindex="0"
    onclick="selectRadio(this)"
    onkeydown="handleKeydown(event, this)">
    <span
      class="radio-icon"
      aria-hidden="true"></span>
    信用卡
  </div>

  <div
    role="radio"
    aria-checked="false"
    tabindex="-1"
    onclick="selectRadio(this)"
    onkeydown="handleKeydown(event, this)">
    <span
      class="radio-icon"
      aria-hidden="true"></span>
    支付宝
  </div>

  <div
    role="radio"
    aria-checked="false"
    tabindex="-1"
    onclick="selectRadio(this)"
    onkeydown="handleKeydown(event, this)">
    <span
      class="radio-icon"
      aria-hidden="true"></span>
    微信支付
  </div>
</div>

<script>
  function selectRadio(selectedRadio) {
    const radioGroup = selectedRadio.closest('[role="radiogroup"]');
    const radios = radioGroup.querySelectorAll('[role="radio"]');

    radios.forEach((radio) => {
      const isSelected = radio === selectedRadio;
      radio.setAttribute('aria-checked', isSelected);
      radio.setAttribute('tabindex', isSelected ? '0' : '-1');
    });
  }

  function handleKeydown(event, radio) {
    const radioGroup = radio.closest('[role="radiogroup"]');
    const radios = Array.from(radioGroup.querySelectorAll('[role="radio"]'));
    const currentIndex = radios.indexOf(radio);

    switch (event.key) {
      case 'ArrowDown':
      case 'ArrowRight':
        event.preventDefault();
        const nextIndex = (currentIndex + 1) % radios.length;
        radios[nextIndex].focus();
        selectRadio(radios[nextIndex]);
        break;
      case 'ArrowUp':
      case 'ArrowLeft':
        event.preventDefault();
        const prevIndex = (currentIndex - 1 + radios.length) % radios.length;
        radios[prevIndex].focus();
        selectRadio(radios[prevIndex]);
        break;
      case ' ':
        event.preventDefault();
        selectRadio(radio);
        break;
    }
  }
</script>

4.3 水平布局的单选按钮组

<fieldset class="radio-group-horizontal">
  <legend>选择评分</legend>
  <div class="radio-options">
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="1" />
      <span>1 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="2" />
      <span>2 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="3"
        checked />
      <span>3 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="4" />
      <span>4 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="5" />
      <span>5 星</span>
    </label>
  </div>
</fieldset>

4.4 带描述的选项

<fieldset
  role="radiogroup"
  aria-labelledby="plan-label">
  <legend id="plan-label">选择套餐</legend>

  <label class="radio-card">
    <input
      type="radio"
      name="plan"
      value="basic"
      checked />
    <div class="radio-content">
      <strong>基础版</strong>
      <span class="price">¥29/月</span>
      <p class="description">适合个人用户,包含基础功能</p>
    </div>
  </label>

  <label class="radio-card">
    <input
      type="radio"
      name="plan"
      value="pro" />
    <div class="radio-content">
      <strong>专业版</strong>
      <span class="price">¥99/月</span>
      <p class="description">适合小型团队,包含高级功能</p>
    </div>
  </label>

  <label class="radio-card">
    <input
      type="radio"
      name="plan"
      value="enterprise" />
    <div class="radio-content">
      <strong>企业版</strong>
      <span class="price">¥299/月</span>
      <p class="description">适合大型企业,包含全部功能</p>
    </div>
  </label>
</fieldset>

五、常见应用场景

5.1 性别选择

<fieldset>
  <legend>性别</legend>
  <label
    ><input
      type="radio"
      name="gender"
      value="male" />
    男</label
  >
  <label
    ><input
      type="radio"
      name="gender"
      value="female" />
    女</label
  >
  <label
    ><input
      type="radio"
      name="gender"
      value="other" />
    其他</label
  >
  <label
    ><input
      type="radio"
      name="gender"
      value="secret"
      checked />
    保密</label
  >
</fieldset>

5.2 支付方式选择

<fieldset>
  <legend>选择支付方式</legend>
  <label class="payment-option">
    <input
      type="radio"
      name="payment"
      value="credit-card"
      checked />
    <img
      src="credit-card-icon.svg"
      alt="" />
    信用卡
  </label>
  <label class="payment-option">
    <input
      type="radio"
      name="payment"
      value="alipay" />
    <img
      src="alipay-icon.svg"
      alt="" />
    支付宝
  </label>
  <label class="payment-option">
    <input
      type="radio"
      name="payment"
      value="wechat" />
    <img
      src="wechat-icon.svg"
      alt="" />
    微信支付
  </label>
</fieldset>

5.3 主题切换

<fieldset class="theme-selector">
  <legend>选择主题</legend>
  <div class="theme-options">
    <label class="theme-option">
      <input
        type="radio"
        name="theme"
        value="light"
        checked />
      <span class="theme-preview light"></span>
      浅色
    </label>
    <label class="theme-option">
      <input
        type="radio"
        name="theme"
        value="dark" />
      <span class="theme-preview dark"></span>
      深色
    </label>
    <label class="theme-option">
      <input
        type="radio"
        name="theme"
        value="auto" />
      <span class="theme-preview auto"></span>
      跟随系统
    </label>
  </div>
</fieldset>

六、最佳实践

6.1 优先使用原生单选按钮

原生 HTML <input type="radio"> 提供完整的无障碍支持,包括:

  • 自动键盘交互(方向键导航)
  • 自动互斥选择
  • 屏幕阅读器自动播报状态
  • 浏览器原生样式和焦点管理

6.2 始终设置默认选中

为避免用户忘记选择,通常应该预设一个默认选项:

<!-- 推荐:预设默认选项 -->
<fieldset>
  <legend>选择语言</legend>
  <label
    ><input
      type="radio"
      name="language"
      value="zh"
      checked />
    中文</label
  >
  <label
    ><input
      type="radio"
      name="language"
      value="en" />
    English</label
  >
</fieldset>

6.3 使用 fieldset 和 legend 分组

始终使用 <fieldset><legend> 对单选按钮进行语义化分组:

<fieldset>
  <legend>选择尺寸</legend>
  <label
    ><input
      type="radio"
      name="size"
      value="s" />
    S</label
  >
  <label
    ><input
      type="radio"
      name="size"
      value="m"
      checked />
    M</label
  >
  <label
    ><input
      type="radio"
      name="size"
      value="l" />
    L</label
  >
  <label
    ><input
      type="radio"
      name="size"
      value="xl" />
    XL</label
  >
</fieldset>

6.4 提供清晰的视觉指示

确保选中和未选中状态有清晰的视觉区别:

/* 自定义单选按钮样式 */
input[type='radio'] {
  width: 20px;
  height: 20px;
  accent-color: #005a9c;
}

input[type='radio']:focus {
  outline: 2px solid #005a9c;
  outline-offset: 2px;
}

6.5 避免嵌套交互元素

不要在单选按钮标签内嵌套其他交互元素:

<!-- 不推荐 -->
<label>
  <input
    type="radio"
    name="option"
    value="a" />
  选项 A <a href="/details">查看详情</a>
</label>

<!-- 推荐 -->
<div>
  <label>
    <input
      type="radio"
      name="option"
      value="a" />
    选项 A
  </label>
  <a href="/details">查看详情</a>
</div>

6.6 考虑移动端触摸区域

确保单选按钮有足够的触摸区域(至少 44x44px):

.radio-label {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px;
  min-height: 44px;
}

七、Radio 与 Select 的选择

场景 推荐组件 原因
选项少于 5 个 Radio 所有选项可见,便于比较
选项多于 7 个 Select 节省空间,避免认知负担
需要显示选项详情 Radio 可以展示描述信息
空间受限 Select 下拉菜单更紧凑
频繁切换 Radio 减少点击次数

八、总结

构建无障碍的单选按钮组件需要关注三个核心:正确的语义化分组(<fieldset><legend>)、清晰的选中状态指示、以及良好的键盘导航支持(方向键切换)。与 Checkbox 不同,Radio 强调互斥选择,适用于需要从一组选项中精确选择单一项目的场景。

遵循 W3C Radio Pattern 规范,我们能够创建既美观又包容的单选按钮组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

昨天以前首页

构建无障碍组件之Checkbox pattern

作者 anOnion
2026年2月23日 21:23

Checkbox Pattern 详解:构建无障碍复选框组件

复选框(Checkbox)是表单中最常见的交互元素之一,支持双状态(选中/未选中)和三状态(选中/未选中/部分选中)两种类型。本文基于 W3C WAI-ARIA Checkbox Pattern 规范,详解如何构建无障碍的复选框组件。

一、Checkbox 的定义与核心概念

复选框是一种允许用户进行二元或三元选择的控件。根据使用场景,复选框分为两种类型:

1.1 双状态复选框(Dual-State Checkbox)

在两个状态之间切换:

  • 选中(Checked):复选框被选中
  • 未选中(Not Checked):复选框未被选中

1.2 三状态复选框(Tri-State Checkbox)

在三个状态之间切换:

  • 选中(Checked):复选框被选中
  • 未选中(Not Checked):复选框未被选中
  • 部分选中(Partially Checked):表示一组选项中部分被选中

1.3 三状态复选框的典型应用场景

三状态复选框常用于软件安装程序或权限设置中,一个总控复选框控制整组选项的状态:

  • 全部选中:如果组内所有选项都被选中,总控复选框显示为选中状态
  • 部分选中:如果组内部分选项被选中,总控复选框显示为部分选中状态
  • 全部未选中:如果组内没有选项被选中,总控复选框显示为未选中状态

用户可以通过点击总控复选框一次性改变整组选项的状态:

  • 点击选中的总控复选框 → 取消全选
  • 点击未选中的总控复选框 → 全选
  • 点击部分选中的总控复选框 → 根据实现可能全选或恢复之前的状态

二、WAI-ARIA 角色与属性

2.1 基本角色

复选框具有 role="checkbox"

2.2 可访问标签

复选框的可访问标签可以通过以下方式提供:

  • 可见文本内容:直接包含在具有 role="checkbox" 的元素内的文本
  • aria-labelledby:引用包含标签文本的元素的 ID
  • aria-label:直接在复选框元素上设置标签文本
<!-- 方式一:可见文本内容 -->
<div role="checkbox" aria-checked="false">
  订阅新闻邮件
</div>

<!-- 方式二:aria-labelledby -->
<span id="newsletter-label">订阅新闻邮件</span>
<div role="checkbox" aria-checked="false" aria-labelledby="newsletter-label"></div>

<!-- 方式三:aria-label -->
<div role="checkbox" aria-checked="false" aria-label="订阅新闻邮件"></div>

2.3 状态属性

2.4 分组属性

如果一组复选框作为逻辑组呈现且有可见标签:

<fieldset role="group" aria-labelledby="group-label">
  <legend id="group-label">选择权限</legend>
  <label><input type="checkbox" /> 读取</label>
  <label><input type="checkbox" /> 写入</label>
  <label><input type="checkbox" /> 删除</label>
</fieldset>

2.5 描述属性

如果包含额外的描述性静态文本,使用 aria-describedby

<div role="checkbox" aria-checked="false" aria-describedby="terms-desc">
  我同意服务条款
</div>
<p id="terms-desc">点击此处查看完整的服务条款内容</p>

三、键盘交互规范

当复选框获得焦点时:

按键 功能
Space 改变复选框的状态(选中/未选中/部分选中)

四、实现方式

4.1 双状态复选框

原生 HTML 实现(推荐)
<label>
  <input type="checkbox" name="newsletter" />
  订阅新闻邮件
</label>
ARIA 实现(自定义样式)
<div 
  role="checkbox" 
  tabindex="0" 
  aria-checked="false"
  onclick="toggleCheckbox(this)"
  onkeydown="handleKeydown(event, this)">
  <span class="checkbox-icon" aria-hidden="true"></span>
  订阅新闻邮件
</div>

<script>
  function toggleCheckbox(checkbox) {
    const isChecked = checkbox.getAttribute('aria-checked') === 'true';
    checkbox.setAttribute('aria-checked', !isChecked);
  }
  
  function handleKeydown(event, checkbox) {
    if (event.key === ' ') {
      event.preventDefault();
      toggleCheckbox(checkbox);
    }
  }
</script>

4.2 三状态复选框(全选/取消全选)

<fieldset role="group" aria-labelledby="permissions-label">
  <legend id="permissions-label">文件权限</legend>
  
  <!-- 总控复选框 -->
  <label>
    <input 
      type="checkbox" 
      id="select-all"
      aria-checked="false"
      onchange="toggleAll(this)" />
    全选
  </label>
  
  <!-- 子复选框组 -->
  <div class="checkbox-group">
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="read"
        onchange="updateSelectAll()" />
      读取
    </label>
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="write"
        onchange="updateSelectAll()" />
      写入
    </label>
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="delete"
        onchange="updateSelectAll()" />
      删除
    </label>
  </div>
</fieldset>

<script>
  function toggleAll(selectAllCheckbox) {
    const checkboxes = document.querySelectorAll('input[name="permission"]');
    const isChecked = selectAllCheckbox.checked;
    
    checkboxes.forEach(checkbox => {
      checkbox.checked = isChecked;
    });
    
    updateSelectAllState();
  }
  
  function updateSelectAll() {
    updateSelectAllState();
  }
  
  function updateSelectAllState() {
    const selectAllCheckbox = document.getElementById('select-all');
    const checkboxes = document.querySelectorAll('input[name="permission"]');
    const checkedCount = document.querySelectorAll('input[name="permission"]:checked').length;
    
    if (checkedCount === 0) {
      selectAllCheckbox.checked = false;
      selectAllCheckbox.indeterminate = false;
      selectAllCheckbox.setAttribute('aria-checked', 'false');
    } else if (checkedCount === checkboxes.length) {
      selectAllCheckbox.checked = true;
      selectAllCheckbox.indeterminate = false;
      selectAllCheckbox.setAttribute('aria-checked', 'true');
    } else {
      selectAllCheckbox.checked = false;
      selectAllCheckbox.indeterminate = true;
      selectAllCheckbox.setAttribute('aria-checked', 'mixed');
    }
  }
</script>

4.3 使用原生 HTML 实现三状态效果

HTML5 的 indeterminate 属性可以实现部分选中视觉效果:

<label>
  <input 
    type="checkbox" 
    id="master-checkbox"
    onclick="handleMasterClick(this)" />
  全选
</label>

<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 1</label>
<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 2</label>
<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 3</label>

<script>
  function updateMaster() {
    const master = document.getElementById('master-checkbox');
    const children = document.querySelectorAll('.child-checkbox');
    const checkedCount = document.querySelectorAll('.child-checkbox:checked').length;
    
    if (checkedCount === 0) {
      master.checked = false;
      master.indeterminate = false;
    } else if (checkedCount === children.length) {
      master.checked = true;
      master.indeterminate = false;
    } else {
      master.checked = false;
      master.indeterminate = true;
    }
  }
  
  function handleMasterClick(master) {
    const children = document.querySelectorAll('.child-checkbox');
    const isChecked = master.checked;
    
    children.forEach(child => {
      child.checked = isChecked;
    });
  }
</script>

五、常见应用场景

5.1 表单选项

用户注册表单中的选项选择:

<fieldset>
  <legend>兴趣爱好</legend>
  <label><input type="checkbox" name="hobby" value="reading" /> 阅读</label>
  <label><input type="checkbox" name="hobby" value="sports" /> 运动</label>
  <label><input type="checkbox" name="hobby" value="music" /> 音乐</label>
  <label><input type="checkbox" name="hobby" value="travel" /> 旅行</label>
</fieldset>

5.2 权限设置

系统权限管理中的功能授权:

<fieldset role="group" aria-labelledby="permissions-heading">
  <h3 id="permissions-heading">用户权限</h3>
  
  <label>
    <input type="checkbox" id="select-all-permissions" />
    全选所有权限
  </label>
  
  <div class="permission-group">
    <label><input type="checkbox" name="permission" value="view" /> 查看数据</label>
    <label><input type="checkbox" name="permission" value="create" /> 创建记录</label>
    <label><input type="checkbox" name="permission" value="edit" /> 编辑内容</label>
    <label><input type="checkbox" name="permission" value="delete" /> 删除数据</label>
  </div>
</fieldset>

5.3 安装程序选项

软件安装时的组件选择:

<fieldset>
  <legend>选择安装组件</legend>
  
  <label>
    <input type="checkbox" id="select-all-components" />
    安装所有组件
  </label>
  
  <label><input type="checkbox" name="component" value="core" checked disabled /> 核心程序(必需)</label>
  <label><input type="checkbox" name="component" value="docs" /> 帮助文档</label>
  <label><input type="checkbox" name="component" value="plugins" /> 插件包</label>
  <label><input type="checkbox" name="component" value="shortcuts" /> 桌面快捷方式</label>
</fieldset>

5.4 表格行选择

数据表格中的批量操作:

<table role="grid">
  <thead>
    <tr>
      <th>
        <input type="checkbox" id="select-all-rows" aria-label="选择所有行" />
      </th>
      <th>姓名</th>
      <th>邮箱</th>
      <th>状态</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><input type="checkbox" class="row-checkbox" aria-label="选择张三" /></td>
      <td>张三</td>
      <td>zhangsan@example.com</td>
      <td>活跃</td>
    </tr>
    <tr>
      <td><input type="checkbox" class="row-checkbox" aria-label="选择李四" /></td>
      <td>李四</td>
      <td>lisi@example.com</td>
      <td>待审核</td>
    </tr>
  </tbody>
</table>

六、最佳实践

6.1 优先使用原生复选框

原生 HTML <input type="checkbox"> 提供完整的无障碍支持,包括:

  • 自动键盘交互(Space 键切换)
  • 屏幕阅读器自动播报状态
  • 浏览器原生样式和焦点管理

6.2 标签关联

始终使用 <label> 元素关联复选框和标签文本:

<!-- 推荐:使用 for 属性关联 -->
<input type="checkbox" id="agree" />
<label for="agree">我同意服务条款</label>

<!-- 推荐:使用嵌套方式 -->
<label>
  <input type="checkbox" />
  我同意服务条款
</label>

6.3 分组语义

相关复选框应使用 <fieldset><legend> 进行分组:

<fieldset>
  <legend>选择通知方式</legend>
  <label><input type="checkbox" /> 邮件通知</label>
  <label><input type="checkbox" /> 短信通知</label>
  <label><input type="checkbox" /> 应用内通知</label>
</fieldset>

6.4 状态同步

三状态复选框需要确保 DOM 属性与 ARIA 属性同步:

function updateTriState(checkbox, checkedCount, totalCount) {
  if (checkedCount === 0) {
    checkbox.checked = false;
    checkbox.indeterminate = false;
    checkbox.setAttribute('aria-checked', 'false');
  } else if (checkedCount === totalCount) {
    checkbox.checked = true;
    checkbox.indeterminate = false;
    checkbox.setAttribute('aria-checked', 'true');
  } else {
    checkbox.checked = false;
    checkbox.indeterminate = true;
    checkbox.setAttribute('aria-checked', 'mixed');
  }
}

6.5 视觉指示

确保复选框状态有清晰的视觉指示:

  • 未选中:空框
  • 选中:勾选标记
  • 部分选中:横线或减号

6.6 焦点管理

为自定义复选框提供清晰的焦点样式:

[role="checkbox"]:focus {
  outline: 2px solid #005a9c;
  outline-offset: 2px;
}

七、Checkbox 与 Radio 的区别

特性 Checkbox Radio
选择数量 可多选 单选
状态数 2 或 3 种 2 种(选中/未选中)
分组方式 逻辑分组 同一 name 属性互斥
典型用途 多选项、权限设置 单选项、性别选择
键盘交互 Space 切换 Arrow 移动选择

八、总结

构建无障碍的复选框组件需要关注三个核心:正确的语义化标记(优先使用原生 <input type="checkbox">)、清晰的状态管理(aria-checked 属性)、以及良好的标签关联(<label> 元素)。对于复杂的三状态场景,需要确保总控复选框与子复选框之间的状态同步,为屏幕阅读器用户提供准确的状态反馈。

遵循 W3C Checkbox Pattern 规范,我们能够创建既美观又包容的复选框组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

构建无障碍组件之Accordion Pattern

作者 anOnion
2026年2月19日 16:38

Accordion Pattern 详解:构建垂直堆叠的展开收起组件

Accordion(手风琴)是一种常见的交互组件,由垂直堆叠的可交互标题组成,每个标题包含一个内容部分的标题、摘要或缩略图。本文基于 W3C WAI-ARIA Accordion Pattern 规范,详解如何构建无障碍的 Accordion 组件。

一、Accordion 的定义与核心概念

Accordion 是一组垂直堆叠的交互式标题,每个标题都包含一个内容部分的标题、摘要或缩略图。标题作为控件,允许用户显示或隐藏其关联的内容部分。

Accordion 常用于在单个页面上呈现多个内容部分时减少滚动需求。

1.1 核心术语

  • Accordion Header(手风琴标题):内容部分的标签或缩略图,同时作为显示(在某些实现中也包括隐藏)内容部分的控件
  • Accordion Panel(手风琴面板):与手风琴标题关联的内容部分

在某些 Accordion 中,手风琴标题旁边始终可见额外的元素。例如,每个手风琴标题可能伴随一个菜单按钮,用于提供适用于该部分的操作访问。

二、WAI-ARIA 角色与属性

2.1 基本角色

每个手风琴标题的内容包含在具有 role="button" 的元素中。

2.2 标题层级

每个手风琴标题按钮包装在具有 role="heading" 的元素中,并设置适合页面信息架构的 aria-level 值:

  • 如果原生宿主语言具有隐式标题和 aria-level 的元素(如 HTML 标题标签),可以使用原生宿主语言元素
  • 按钮元素是标题元素内部的唯一元素
<!-- 手风琴标题 -->
<h3>
  <button aria-expanded="true" aria-controls="panel-1" id="accordion-header-1">
    第一部分标题
  </button>
</h3>

<!-- 手风琴面板 -->
<div id="panel-1" role="region" aria-labelledby="accordion-header-1">
  <p>第一部分的内容...</p>
</div>

2.3 状态属性

  • aria-expanded:如果与手风琴标题关联的面板可见,设置为 true;如果面板不可见,设置为 false
  • aria-controls:设置为包含手风琴面板内容的元素的 ID
  • aria-disabled:如果与手风琴标题关联的面板可见,且手风琴不允许折叠该面板,则设置为 true

2.4 区域角色(可选)

每个作为面板内容容器的元素可以具有 role="region"aria-labelledby,其值引用控制面板显示的按钮:

  • 避免在会创建过多地标区域的情况下使用 region 角色,例如在可以同时展开超过约 6 个面板的手风琴中
  • 当面板包含标题元素或嵌套手风琴时,region 角色对屏幕阅读器用户感知结构特别有帮助
<!-- 手风琴标题按钮 -->
<h3>
  <button aria-expanded="true" aria-controls="panel-1" id="header-1">
    面板标题
  </button>
</h3>

<!-- 手风琴面板内容 -->
<div role="region" aria-labelledby="header-1" id="panel-1">
  <p>面板内容...</p>
</div>

三、键盘交互规范

3.1 基本键盘操作

按键 功能
Enter 或 Space 当焦点位于折叠面板的手风琴标题上时,展开关联面板。如果实现只允许一个面板展开,且另一个面板已展开,则折叠该面板
Tab 将焦点移动到下一个可聚焦元素;手风琴中的所有可聚焦元素都包含在页面 Tab 序列中
Shift + Tab 将焦点移动到上一个可聚焦元素;手风琴中的所有可聚焦元素都包含在页面 Tab 序列中

3.2 可选键盘操作

按键 功能
Down Arrow 如果焦点在手风琴标题上,将焦点移动到下一个手风琴标题。如果焦点在最后一个手风琴标题上,要么不执行任何操作,要么将焦点移动到第一个手风琴标题
Up Arrow 如果焦点在手风琴标题上,将焦点移动到上一个手风琴标题。如果焦点在第一个手风琴标题上,要么不执行任何操作,要么将焦点移动到最后一个手风琴标题
Home 当焦点在手风琴标题上时,将焦点移动到第一个手风琴标题
End 当焦点在手风琴标题上时,将焦点移动到最后一个手风琴标题

四、实现方式

4.1 基础结构

<div class="accordion">
  <!-- 第一部分 -->
  <h3>
    <button 
      aria-expanded="true" 
      aria-controls="section1"
      id="accordion-header-1">
      第一部分标题
    </button>
  </h3>
  <div 
    id="section1" 
    role="region" 
    aria-labelledby="accordion-header-1">
    <p>第一部分的内容...</p>
  </div>

  <!-- 第二部分 -->
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="section2"
      id="accordion-header-2">
      第二部分标题
    </button>
  </h3>
  <div 
    id="section2" 
    role="region" 
    aria-labelledby="accordion-header-2"
    hidden>
    <p>第二部分的内容...</p>
  </div>
</div>

4.2 单展开模式

在单展开模式下,一次只能展开一个面板:

<div class="accordion" data-accordion-single>
  <h3>
    <button 
      aria-expanded="true" 
      aria-controls="panel-1"
      aria-disabled="true">
      始终展开的面板
    </button>
  </h3>
  <div id="panel-1" role="region">
    <p>此面板无法折叠...</p>
  </div>
  
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="panel-2">
      可切换的面板
    </button>
  </h3>
  <div id="panel-2" role="region" hidden>
    <p>点击上方标题可展开此面板...</p>
  </div>
</div>

4.3 多展开模式

在多展开模式下,可以同时展开多个面板:

<div class="accordion" data-accordion-multiple>
  <h3>
    <button aria-expanded="true" aria-controls="multi-1">
      第一个面板
    </button>
  </h3>
  <div id="multi-1" role="region">
    <p>第一个面板内容...</p>
  </div>
  
  <h3>
    <button aria-expanded="true" aria-controls="multi-2">
      第二个面板(也可同时展开)
    </button>
  </h3>
  <div id="multi-2" role="region">
    <p>第二个面板内容...</p>
  </div>
</div>

4.4 使用原生 HTML <details> + name 实现

HTML5.2 起,<details> 元素支持 name 属性,可以实现原生的单展开模式(Accordion 效果),无需 JavaScript:

<details name="accordion-group" open>
  <summary>第一部分标题</summary>
  <p>第一部分的内容...</p>
</details>

<details name="accordion-group">
  <summary>第二部分标题</summary>
  <p>第二部分的内容...</p>
</details>

<details name="accordion-group">
  <summary>第三部分标题</summary>
  <p>第三部分的内容...</p>
</details>
关键点说明
特性 说明
name 属性 相同 name 值的 <details> 元素会互斥,实现单展开
open 属性 指定默认展开的面板
浏览器支持 Chrome 120+, Firefox, Safari 17.1+
增强版实现(添加 heading 结构)

⚠️ 注意<details> 元素的实现方式与 W3C Accordion Pattern 的 DOM 结构要求不完全一致。W3C 标准要求按钮元素必须是 heading 元素内部的唯一子元素(<h3><button>...</button></h3>),而 <details> 使用 <summary> 作为交互元素。

如果需要更好的无障碍支持,可以在 <summary> 内添加标题:

<details name="accordion-group" open>
  <summary>
    <h3 style="display: inline; font-size: inherit;">第一部分标题</h3>
  </summary>
  <p>第一部分的内容...</p>
</details>

重要提示:这种结构虽然添加了 heading,但仍然是 heading 在 summary 内部,与 W3C 要求的 button 在 heading 内部 的结构相反。因此,这种方式:

  • ✅ 提供了基本的标题层级信息
  • ❌ 不完全符合 W3C Accordion Pattern 的 DOM 结构规范
  • ❌ 可能不被某些屏幕阅读器正确识别为手风琴组件
适用场景

推荐使用 <details name>

  • 简单的 FAQ 页面
  • 不需要复杂样式的场景
  • 追求原生、轻量实现
  • 现代浏览器环境

推荐使用 W3C 模式:

  • 需要多展开模式
  • 需要箭头键导航
  • 需要精确的标题层级(SEO/屏幕阅读器)
  • 需要复杂的自定义样式

五、常见应用场景

5.1 表单分步填写

将长表单分成多个部分,用户逐步填写:

<div class="accordion">
  <h3>
    <button aria-expanded="true" aria-controls="step-1">
      步骤 1:个人信息
    </button>
  </h3>
  <div id="step-1" role="region">
    <label>姓名 <input type="text" /></label>
    <label>邮箱 <input type="email" /></label>
  </div>
  
  <h3>
    <button aria-expanded="false" aria-controls="step-2">
      步骤 2:地址信息
    </button>
  </h3>
  <div id="step-2" role="region" hidden>
    <label>城市 <input type="text" /></label>
    <label>邮编 <input type="text" /></label>
  </div>
</div>

5.2 FAQ 页面

常见问题解答页面,每个问题作为一个可展开的部分:

<div class="accordion">
  <h3>
    <button aria-expanded="false" aria-controls="faq-1">
      如何注册账户?
    </button>
  </h3>
  <div id="faq-1" role="region" hidden>
    <p>点击页面右上角的"注册"按钮,填写必要信息...</p>
  </div>
  
  <h3>
    <button aria-expanded="false" aria-controls="faq-2">
      如何重置密码?
    </button>
  </h3>
  <div id="faq-2" role="region" hidden>
    <p>点击登录页面的"忘记密码"链接...</p>
  </div>
</div>

5.3 设置面板

应用程序的设置页面,将相关设置分组:

<div class="accordion">
  <h3>
    <button aria-expanded="true" aria-controls="settings-general">
      通用设置
    </button>
  </h3>
  <div id="settings-general" role="region">
    <label><input type="checkbox" /> 启用通知</label>
    <label><input type="checkbox" /> 自动保存</label>
  </div>
  
  <h3>
    <button aria-expanded="false" aria-controls="settings-privacy">
      隐私设置
    </button>
  </h3>
  <div id="settings-privacy" role="region" hidden>
    <label><input type="checkbox" /> 公开个人资料</label>
    <label><input type="checkbox" /> 允许搜索</label>
  </div>
</div>

六、最佳实践

6.1 语义化标记

  • 使用适当的标题层级(h1-h6)包装手风琴标题按钮
  • 为每个面板添加 role="region" 以增强结构感知(面板数量较少时)
  • 确保按钮元素是标题元素内部的唯一元素

6.2 键盘导航

  • 实现基本的 Enter/Space 和 Tab 导航
  • 可选实现箭头键导航以提升用户体验
  • 确保所有手风琴标题都包含在 Tab 序列中

6.3 视觉指示

  • 使用清晰的视觉指示器表示展开/折叠状态
  • 为当前聚焦的标题提供明显的焦点样式
  • 考虑使用动画过渡提升用户体验

6.4 状态管理

  • 明确区分单展开和多展开模式
  • 在单展开模式中,考虑是否允许所有面板同时折叠
  • 使用 aria-disabled 表示不允许折叠的面板

6.5 嵌套考虑

  • 避免过深的嵌套层级
  • 嵌套手风琴时,确保每个层级有清晰的视觉区分
  • 考虑使用不同的标题层级表示嵌套关系

七、Accordion 与 Disclosure 的区别

特性 Accordion Disclosure
内容组织 多个垂直堆叠的面板 单个内容块
展开模式 支持单展开或多展开 独立控制
标题结构 使用 heading + button 结构 简单按钮或 summary
导航支持 支持箭头键导航 基本 Tab 导航
用途 表单分步、设置面板、FAQ 详细信息展示

八、总结

构建无障碍的 Accordion 组件需要关注三个核心:正确的语义化标记(heading + button 结构)、完整的键盘交互支持(包括可选的箭头键导航)、清晰的状态管理(aria-expanded、aria-controls、aria-disabled)。与简单的 Disclosure 不同,Accordion 强调多个面板的组织和管理,适用于更复杂的内容展示场景。

遵循 W3C Accordion Pattern 规范,我们能够创建既美观又包容的手风琴组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

❌
❌