关于 semantic-html
用于编写语义清晰且易访问的 HTML 的指导技能,面向构建或审查页面与组件的开发者,强调优先使用原生元素、明确地标和标题层级。
主要功能
- 01规划页面地标与标题层级
- 02优先使用原生 HTML,避免冗余 ARIA
- 03为内容选择合适的语义元素
- 04添加键盘导航跳转链接
- 05构建易访问的表单与交互控件
适用场景
- 01检查标记的语义与易访问性
- 02构建结构清晰的页面区块
- 03创建键盘可操作的表单与控件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ schalkneethling/ webdev-agent-skills --skill semantic-html安装后在 Claude Code 中输入/semantic-html确认已加载。
附带文件references/element-decision-trees.mdreferences/heading-patterns.md
详细介绍
语义化 HTML
编写能够传达含义、服务所有用户并遵循 Web 平台规范的 HTML。
何时使用此技能
在以下情况下使用此技能:
- 创建新组件或页面区块
- 检查标记的无障碍性和语义
- 在原生 HTML 元素与 ARIA 属性之间做出选择
- 使用正确的标题层级构建文档结构
- 让交互元素具备无障碍性
- 构建带有正确标签和错误处理的表单
- 创建响应式表格
核心原则
内容的真实性
设计中的内容往往经过理想化处理,而真实内容则杂乱多变。请始终考虑:
- 较长的句子和单词
- 宽高比和尺寸各异的图片
- 多语言支持(即使暂未计划,用户也可以通过浏览器翻译)
- 长度和结构会变化的动态内容
构建能够妥善处理真实内容的组件,而不仅仅是适合设计工具中展示的内容。
优先规划地标
在深入设计单个组件之前,先考虑完整的页面结构。这样可以:
- 为辅助技术用户识别关键地标
- 规划整个文档的标题层级
- 做出明智的元素选择
- 避免过度使用地标(这会削弱其作用)
优先使用原生 HTML,而非 ARIA
遵循 ARIA 的第一条规则:如果原生 HTML 元素能够提供所需的语义和行为,就使用它,而不是给通用元素添加 ARIA。
警示信号: 对于不复杂的组件,如果 div 数量和非复杂组件上的 ARIA 数量都很高,说明你在不断打补丁,而不是从基础着手。
冗余的 ARIA
给已经具有正确语义的元素添加 ARIA 是多余的——这会使代码变得杂乱,可能令辅助技术产生困惑,也会掩盖真正的意图:
<ul role="list">
...
</ul>
<img src="avatar.png" alt="" role="presentation" />
<span aria-label="Most Popular">Most Popular</span>
不要使用 role 覆盖原生语义
ARIA role 会改变辅助技术对元素的解读。应用会改变原生元素语义的 role 会导致不一致——原生行为(键盘交互、状态、事件)保持不变,但播报的角色却发生变化:
<input type="checkbox" role="switch" />
<input type="checkbox" />
只有在确实需要不同语义,且元素的行为真正符合该角色时,才为原生元素添加 role 属性。
将视觉层级与语义层级分离
视觉样式与语义含义彼此相关,但并不绑定。CSS 类可以弥合二者之间的差异:
- 根据文档结构使用合适的标题级别
- 使用 CSS 类控制视觉外观(大小、粗细、颜色)
- 创建
.u-Heading-XXL等实用类,以便在不同语义级别下保持一致的视觉样式
文档结构
跳过导航链接
跳过链接可让键盘和屏幕阅读器用户绕过重复的导航区块,直接跳转到有意义的内容。任何在主要内容前包含导航区块或其他重复内容的页面都必须提供跳过链接。
将跳过链接放在 <body> 中第一个可聚焦的元素。它们可以在视觉上隐藏,并在获得焦点时显示:
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<a href="#search" class="skip-link">Skip to search</a>
<header>...</header>
<nav>...</nav>
<main id="main-content" tabindex="-1">...</main>
</body>
.skip-link {
position: absolute;
transform: translateY(-100%);
}
.skip-link:focus {
transform: translateY(0);
}
为什么要在 <main> 上使用 tabindex="-1": <main> 元素默认无法获得焦点。如果没有 tabindex="-1",激活跳过链接后会滚动到该元素,但在某些浏览器中不会将键盘焦点移至此处。添加 tabindex="-1" 后,它就能以编程方式获得焦点(可通过跳过链接或 .focus() 访问),但不会加入自然 Tab 顺序。
何时添加更多跳过链接: 如果页面包含醒目的搜索栏、侧边栏或较长的次级导航,也可以考虑为这些目标添加跳过链接。这样做是为了减少到达主要内容所需的 Tab 按键次数。
侧边栏布局需要“跳至导航”链接: 如果侧边栏导航位于 DOM 顶部之外(例如,在源码顺序中位于 <main> 之后,或深藏在布局中),请添加一个指向 <nav> 的跳过链接,让键盘用户无需先遍历所有主要内容即可访问导航。
主要跳过链接应指向 <main>: 第一个跳过链接应始终指向 <main id="main-content">。根据页面的复杂程度,其他跳过链接可以指向其他有意义的地标或控件,例如 <search> 元素、侧边栏 <nav> 或醒目的表单。
为什么这很重要: 没有跳过链接时,键盘用户每次加载页面都必须按 Tab 键遍历每个导航项。对于包含 12 个链接的导航,每个页面都要多按 12 次键。
地标元素
使用地标元素来传达页面结构:
| 元素 | 使用场景 | 备注 |
|---|---|---|
header | 页面或章节页眉 | 可在不同上下文中多次出现 |
footer | 页面或章节页脚 | 联系信息、版权信息、相关链接 |
nav | 导航区域 | 必须添加标签;避免在标签中使用“导航”(屏幕阅读器会读出该词) |
main | 主要内容 | 每页只能有一个;必须包含主要的 <h1> |
aside | 相关但非核心的内容 | 移除后不会改变页面主要内容的内容(侧边栏、广告) |
search | 搜索功能 | 包含搜索表单,而非搜索结果 |
form | 用户输入 | 只有通过 aria-labelledby 或 aria-label 添加标签后,才会成为地标 |
article | 独立完整的内容 | 适合联合发布或单独呈现 |
section | 主题分组 | 只有添加标签后才会成为地标 |
<main> 必须包含主要的 <h1>
屏幕阅读器用户通常会直接跳转到 <main>。如果页面的 <h1> 位于 <header> 和 <main> 之间的 <div> 中,这些用户就会跳过标题,失去重要的上下文。<h1>(以及介绍页面的副标题或引言)应放在 <main> 内:
<header>...</header>
<div class="page-header"><h1>FAQ</h1></div>
<main>
</main>
<header>...</header>
<main id="main-content" tabindex="-1">
<h1>FAQ</h1>
...
</main>
Section 元素
没有可访问名称的 section 在语义上等同于 div。使用 section 时:
- 通过
aria-labelledby将其与标题关联 - 这样可将其转变为有效的地标区域
- 如果无法提供有意义的标签,请考虑
section是否适合此处
Article 元素
不要只想到博客文章。对于任何独立成篇、脱离上下文也能成立的内容,都可以使用 article:
- 博客文章和新闻报道
- 文章评论
- 列表中的产品卡片
- 信息流中的社交媒体帖子
- 论坛帖子
**测试:**如果将这段内容提取出来,放到别处且不提供上下文,它是否仍然成立?
Address 元素
它经常被误解。根据 HTML 规范:
address 元素表示其最近的 article 或 body 元素祖先的联系信息。
用于作者或所有者的联系信息,而非一般的邮寄地址。对于邮寄地址,请使用标准的 <p> 或符合具体上下文的结构化标记。
<aside> 与 <section>
**<aside> 的判断标准:**如果将这段内容从页面中完全移除,它是否仍然成立?主要内容是否依然完整?
- 广告、相关文章链接或作者简介 →
<aside>(移除后不会改变主要信息) - FAQ 页面上的“仍需帮助?” CTA、文章中的关键发现摘要或醒目的注册提示 →
<section>(移除后会让页面显得不完整,或打断预期的内容流程)
拿不准时:如果内容服务于页面的主要目的,就应放在带标签的 <section> 中,而非 <aside>。
Aside 元素与摘引
<aside> 适用于摘引——一种突出文章文本的排版手法。不过,不要对取自页面自身内容的摘引使用 <blockquote>。<blockquote> 表示外部或独立的引文。对于重述同一篇文章内容的摘引,请在 <aside> 内使用 <p>(或带样式的文本):
<aside aria-label="Pull quote">
<p>
"The biggest gains came not from new features, but from removing old ones."
</p>
</aside>
<blockquote cite="https://example.com/source">
<p>Quote from an external source.</p>
</blockquote>
标题
标题层级
保持逻辑清晰的标题结构:
- 每页使用一个
h1(通常是主标题) - 不要跳过层级(h1 → h3)
- 标题会构成大纲;按顺序阅读时应确保大纲合理
组件中的标题
对于包含标题的可复用组件:
- 使标题级别可配置 — 组件可能出现在不同的上下文中
- 提供合理的默认值 — 并非所有内容作者都了解标题层级
- 考虑继承关系 — 通用组件会演变为特定组件;标题配置应随之传递
示例模式:
Card (generic) → heading level configurable, default h3
└─ ProductCard (specific) → inherits config, may set default based on known context
└─ Used in section with h2 → heading level set to h3
视觉上像标题但语义上不是标题的文本
有时,文本看起来像标题,但在语义上不应被视为标题。使用 CSS 类应用类似标题的样式,而不影响文档大纲:
<p class="u-Heading-L">This looks like a heading</p>
列表
何时使用列表
当了解条目数量对用户有帮助时,列表最有用:
- 导航菜单(有多少个选项?)
- 搜索结果(有多少条匹配项?)
- 图片库(有多少张图片?)
- 流程中的步骤
可以问自己:
- 这些条目是否确实属于同一类别?
- 移除其中一项,其他项是否会显得不完整?
- 是否隐含地传达了“这里有 N 项内容”?
列表类型
| 类型 | 使用场景 | 示例 |
|---|---|---|
ul | 需要体现条目数量的无序集合 | 导航项、搜索结果 |
ol | 顺序步骤或排名条目 | 食谱、说明、Top 10 榜单 |
dl | 术语与描述的配对 | 词汇表、元数据、键值对 |
menu | 工具栏命令 | 操作按钮,而非导航 |
**有序列表属性:**倒计时式列表(例如从 10 到 1 列出 Top 10)使用 reversed。要从特定值开始编号,请使用 start。这两者都是原生 HTML,无需 JavaScript。
定义列表
定义列表常被忽略,或与 details/summary 混淆。以下情况使用 dl:
- 术语表定义
- 元数据展示(标签:值对)
- 任何包含一个或多个描述的术语
注意:单个 dt 可以有多个 dd 元素,用于提供多个相关描述。
装饰性列表分隔符
使用 CSS ::before 或 ::after 插入视觉分隔符(例如面包屑中的 ›)时,浏览器会自动将生成的内容排除在无障碍树之外,因此无需额外标记。不要试图将 aria-hidden: "true" 作为 CSS 属性来隐藏它;这是无效的,也不会产生任何效果。如果通过 HTML(而不是 CSS)插入分隔符,请在 HTML 元素上使用 <span aria-hidden="true">。
交互元素
按钮与链接
传统规则: 按钮用于执行操作,链接用于跳转。
渐进增强视角: 如果 JavaScript 失效时 URL 能提供有意义的后备方案,那么即使是类似操作的交互,也可以使用链接。
| 交互 | 默认选择 | 考虑使用链接的情况 |
|---|---|---|
| 显示更多内容 | button | URL 参数可以在服务器端加载内容 |
| 切换视图(网格/列表) | button | URL 可以保留视图偏好 |
| 复制到剪贴板 | button | 复制的内容是可分享的 URL |
| 选择选项卡 | button | URL 可以加载特定选项卡的内容 |
关键问题: JavaScript 失效时会发生什么?如果 URL 能提供优雅降级,链接可能是更好的选择。
为重复按钮提供唯一的无障碍名称
当同一操作在页面上多次出现时(例如,每张商品卡片上都有“加入购物车”,每篇文章上都有“阅读更多”),每个按钮都需要唯一的无障碍名称,以便屏幕阅读器用户了解它作用于哪个项目。
可采用以下方法(选择最简单的一种):
<button aria-label="Add Nike Pegasus 41 to cart">Add to cart</button>
<button>
Add to cart
<span class="visually-hidden">Nike Pegasus 41</span>
</button>
<article>
<h3 id="product-42">Nike Pegasus 41</h3>
...
<button aria-describedby="product-42">Add to cart</button>
</article>
可见标签应保持为“加入购物车”(视力正常的用户可通过位置理解上下文);无障碍名称则为使用按钮列表导航的用户补充商品名称。
禁用控件
aria-disabled="true" 用于传达禁用状态,但不会阻止交互。对于按钮,disabled 既能传达状态,也能阻止点击和键盘激活。对于链接,仅使用 aria-disabled="true" 并不够——链接仍会获得焦点并可被激活。可选方案:
- 操作确实不可用时,使用
<button disabled>代替链接 - 移除
href属性以阻止激活(链接将变为非交互元素) - 如果必须让元素保持可聚焦,则明确处理
keydown/click事件
使用 Popover API 创建轻量级浮层
对于用户下拉菜单、操作菜单和非模态浮层,推荐使用现代的 Popover API(popover 属性),而不是自定义 ARIA 小部件模式。
<button popovertarget="user-menu">
<img src="avatar.png" alt="" />
<span>Alice</span>
</button>
<ul id="user-menu" popover>
<li><a href="/profile">Profile</a></li>
<li><a href="/settings">Settings</a></li>
<li><button>Sign out</button></li>
</ul>
当弹出框与调用按钮在 DOM 中不相邻时,浏览器会自动处理调用按钮上的 aria-expanded,以及 aria-details。触发器无需手动添加 ARIA 属性。
为什么它优于 ARIA 菜单模式:
- 浏览器原生处理键盘交互、焦点管理和轻触关闭
- 无需使用
role="menu"、role="menuitem"或role="none"——列表仍是由链接和按钮组成的语义化<ul> - ARIA 菜单模式有严格的交互要求(
Home、End、字符导航),很容易实现错误,且许多用户并不熟悉
适用 ARIA 菜单模式的情况: 仅在构建真正的应用菜单(菜单栏、菜单项、子菜单),且其行为与桌面应用一致时使用。大多数网站导航和用户下拉菜单都应使用 Popover API 或简单的展开/折叠模式。
Details/Summary 模式
用于渐进式披露:
- 常见问题解答(FAQ)部分
- 可展开的内容部分
- 可折叠的导航
不能替代由 <button> 控制的披露组件,后者适用于需要 ARIA 角色(例如 role="menu"、role="dialog")的情况。用户下拉菜单、菜单和模态框触发器需要使用 <button>,以便应用正确的 ARIA 模式。<details>/<summary> 具有自身的隐式语义,无法有效地使用 aria-expanded 或菜单角色。
表单
使用 Fieldset/Legend 分组
使用 fieldset 和 legend 进行主题分组,而不是布局:
- 地址字段
- 个人信息部分
- 隐私/同意复选框
- 付款详情
- 设置部分(个人资料、通知、隐私)
当 section+heading 不够时: 对于表单控件组,<fieldset>/<legend> 能为辅助技术提供分组上下文,而 <section>/<h2> 无法做到这一点。屏幕阅读器会在每个组内字段之前读出 legend,为用户持续提供上下文。非表单内容区域使用 <section>/<h2>;只要区域包含一组输入项,就使用 <fieldset>/<legend>。
优点:
- 支持渐进式披露(随着用户完成其他部分,逐步显示后续部分)
- 减少信息过载(避免出现“表单字段墙”)
- 为屏幕阅读器用户提供上下文
可以在视觉上隐藏 legend,同时保留其可访问名称。
搜索和筛选控件分组
<search> 应包裹整个搜索/筛选界面,而不只是文本输入框。如果工具栏包含搜索输入框和相关筛选选择框,应将它们放在同一个 <search> 或带标签的 <form> 中:
<search aria-label="Filter employees">
<label for="q">Search</label>
<input type="search" id="q" name="q" />
<label for="dept">Department</label>
<select id="dept" name="dept">
...
</select>
<label for="status">Status</label>
<select id="status" name="status">
...
</select>
<button type="submit">Apply filters</button>
</search>
<search>
<input type="search" />
</search>
<select>
...
</select>
标签
始终使用 label 元素。 没有例外。
- 在设计需要时,可以使用视觉隐藏的标签
- 绝不要用占位符文本代替标签
- 如果可以使用合适的
label元素,就绝不要使用aria-label
占位符为何不可靠:
- 输入后会消失(对有认知障碍、承受压力或容易分心的用户来说不便)
- 对比度往往较低
- 无法提供持续的标识
必填字段
HTML 的 required 属性会向辅助技术传达必填状态,但视力正常的用户也需要视觉提示。始终将 required 与可见标记配合使用:
<fieldset>
<legend>
Contact details <span aria-hidden="true">*</span> required fields
</legend>
<label for="name">Full name <span aria-hidden="true">*</span></label>
<input type="text" id="name" required />
</fieldset>
星号上的 aria-hidden 可防止屏幕阅读器读出“星号”——它们已经通过 required 属性得知必填状态。legend 或页面级说明会向视力正常的用户解释这一约定。
提示文本
当输入项带有格式提示或辅助文本时,通过 aria-describedby 将其与输入项关联。这样屏幕阅读器会在标签之后读出提示,让用户在输入前获得所需上下文:
<label for="email">Email address</label>
<p id="email-hint" class="hint">We'll only use this to send your receipt.</p>
<input type="email" id="email" aria-describedby="email-hint" />
可以建立多个关联——以逗号分隔的 ID 既可用于提示,也可用于错误信息:
<input
type="email"
id="email"
aria-invalid="true"
aria-describedby="email-hint email-error"
/>
错误信息
当前最佳实践(由于浏览器对 aria-errormessage 的支持存在不足):
- 在无效输入项上设置
aria-invalid="true" - 通过
aria-describedby关联错误信息 - 确保错误信息可供用户采取行动(说明问题并指导如何修正)
- 对于动态错误(失焦时显示),可考虑在错误容器上使用
aria-live
<label for="email">Email</label>
<input
type="email"
id="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<p id="email-error" class="error">
Enter a valid email address, like [email protected]
</p>
表格
何时使用表格
当数据在两个维度上都存在有意义的关系时,使用表格:
- 数据必须按行和列呈现
- 表头与数据之间有明确的关联
- 每行包含相同的列
- 每列中的数据类型相同
何时不使用表格
- 简单列表(单一维度)
- 键值对(使用
dl) - 表单布局
- 层级数据(使用嵌套列表)
表格语义基础
始终包含:
caption— 描述表格的用途thead、tbody、tfoot— 进行结构分组- 带有
scope的th— 标识表头单元格及其方向
响应式表格
按优先顺序排列:
- 隐藏非必要列 — 用户仍能获取主要信息;提供按钮以显示完整表格
- 水平滚动 — 保留语义,但可能给运动障碍用户带来困难
- 重复组件(在移动设备上显示卡片) — 作为最后手段;确保两个版本都具有可访问性
注意:现代浏览器(包括 Safari)在应用 display: grid 或 display: flex 时,不再移除表格语义,因此带来了新的响应式可能性。
代码审查清单
审查标记时,请检查:
正面信号
- 存在跳过导航链接,且位于第一个可聚焦元素的位置
- 恰当地使用地标元素
- 标题层级合乎逻辑
- 正确使用原生交互元素(按钮、链接)
- 重复的操作按钮具有唯一的可访问名称
- 表单具有适当的标签、字段集和提示关联
- 必填字段有醒目的视觉标记,并通过图例说明标记规则
- 表格具有完整的语义结构
- 谨慎且正确地使用 ARIA
警示信号
-
<nav>或重复的页眉内容之前没有跳过导航链接 - 非复杂组件中
<div>数量过多 - 使用 ARIA 属性弥补原生语义的缺失
- 在原生元素上添加冗余的 ARIA 角色(例如,在
<ul>上添加role="list",在<img alt="">上添加role="presentation") -
role属性改变了原生元素的语义,但行为没有相应变化 -
aria-label重复了可见文本(辅助技术会读两遍,或导致呈现混乱) - 简单的用户下拉菜单使用了 ARIA 菜单模式(
role="menu"、role="menuitem")(应改用 Popover API) - 将占位符用作标签
- 根据视觉大小而非结构选择标题级别
- 使用带有点击处理程序的通用元素,而不是按钮或链接
- 使用表格进行布局
- 缺少表单标签
- 重复按钮具有相同的可访问名称(“Add to cart” × 6)
- 在
<a>元素上使用aria-disabled,但未阻止键盘激活 - 筛选/搜索控件散落在
<search>或<form>分组之外 - 设置表单的各部分使用
<section>/<h2>,而不是<fieldset>/<legend>
资源
- HTML Living Standard:章节
- HTML Living Standard:内容分组
- HTML Living Standard:表单
- HTML Living Standard:表格
- MDN:ARIA
参考资料
具体主题的详细指南请参阅 references/ 目录:
element-decision-trees.md— 元素选择的快速决策框架heading-patterns.md— 组件标题模式和配置策略