Front-End-Checklist accessible-notifications:用 ARIA Live Regions 构建可被读屏软件感知的通知系统
在 Front-End-Checklist 仓库中,skills/accessible-notifications 是一条高优先级的 HTML/组件类检查规则,聚焦 Toast 通知、Alert 与实时状态更新如何让屏幕阅读器用户"听得到"。本文基于该 Skill 文档及其引用规则 references/rule.md 完整展开:从 aria-live 语义选择、可复制的 React Toast 组件实现,到展示时长策略、样式约束与验证清单,并结合仓库中真实的屏幕阅读器播报工具 screen-reader.ts 剖析底层实现。读完后,你可以独立编写一条符合该规则的通知组件,并知道如何在代码评审中判断实现是否违规。
规则定位与元数据
SKILL.md 的 frontmatter 定义了这条规则的完整元信息,这也是 Agent 检索和使用该规则时的入口:
| 字段 | 取值 | 含义 |
|---|---|---|
category |
html |
归属于 HTML 分类(而非纯 CSS 或性能) |
priority |
high |
高优先级,属于应优先修复项 |
difficulty |
intermediate |
需要理解 ARIA 语义,而非简单加属性 |
estimatedTime |
25 |
预估修复/落实耗时 25 分钟 |
source / url |
frontendchecklist.io |
规则溯源至前端检查清单站点的规则页 |
SKILL.md 把执行过程拆成了四个可复用的提示词段落,这也是该仓库所有 Skill 的统一结构:
- Check:验证通知使用了
aria-live区域、恰当的role(alert或status),且展示时间足够被读完; - Fix:以
role='alert'或role='status'、aria-live='polite'或'assertive'及足够展示时长实现通知; - Explain:向使用者解释为什么无障碍通知能让所有用户不论交互方式都能获知重要更新;
- Code Review:评审模板、服务端渲染 HTML 与共享组件中输出的最终面向浏览器的标记,标记出具体元素、属性与路由,而不只是指出源码框架层的抽象。
最后一点是这条规则区别于普通"组件规范"的关键:审查对象是最终渲染出的 HTML,而不是源码里的组件抽象。同一条规则的完整内容同时维护在 accessible-notifications.mdx,其 frontmatter 还声明了相关规则(如 aria-live-regions、accessible-tooltips、carousel-accessibility)与权威来源(MDN、WHATWG HTML Living Standard)。
核心问题:没有 ARIA 属性的通知等于"隐形"
规则文档给出的动机(Why It Matters)一句话概括了问题本质:
Without proper ARIA attributes, screen reader users miss critical notifications like form errors, success messages, and real-time updates—leaving them unaware of important page changes.
翻译过来就是:一个普通的 <div> Toast 在 DOM 中出现了、消失了,屏幕阅读器对此毫无感知——视障用户既不知道表单提交失败,也不知道保存成功。原因在于屏幕阅读器只播报"焦点所在位置"或"被标记为会动态变化的区域",而动态注入的内容若不在 live region 内,变更就完全被忽略。这正是 aria-live-regions 规则 与本规则交叉的原因:前者负责"动态内容变更的播报机制",本规则负责"通知这类具体场景的落地实现"。
ARIA Live Region 类型选择
这是全规则最核心的决策表,决定了每条通知的"打断程度":
| 属性 | 行为 | 适用场景 |
|---|---|---|
aria-live="polite" |
等待用户停顿后再播报 | 状态更新、非紧急信息 |
aria-live="assertive" |
立即打断当前播报 | 错误、时间敏感型告警 |
role="status" |
隐式等同于 polite | 进度、成功消息 |
role="alert" |
隐式等同于 assertive | 错误、警告 |
role="status" 与 role="alert" 的价值在于隐式语义:role="alert" 隐含 aria-live="assertive",role="status" 隐含 aria-live="polite",可以减少重复声明,也让语义意图更清晰。但规则文档在结尾特别警告(原样保留这条告诫):
Don't Overuse Assertive — 把
aria-live="assertive"留给真正紧急的消息。滥用它会通过不断打断屏幕阅读器输出而破坏用户体验。
最小 HTML 实现
规则文档给出的三个基础范式,覆盖了"静态可见通知"、"紧急错误通知"和"纯屏幕阅读器播报容器"三种形态:
<!-- Status notification (polite) -->
<div role="status" aria-live="polite" class="notification">
Your changes have been saved.
</div>
<!-- Alert notification (assertive) -->
<div role="alert" aria-live="assertive" class="notification notification--error">
Error: Please fill in all required fields.
</div>
<!-- Live region container (content injected dynamically) -->
<div
id="notifications"
aria-live="polite"
aria-atomic="true"
class="sr-only"
></div>
三个要点:
aria-atomic="true":区域内容被整体替换时,屏幕阅读器播报整个区域而非仅 diff 片段,适合通知这类"整句有意义"的内容;sr-only容器:第三种形态是视觉上完全隐藏、仅读屏可见的"公告区",用于内容动态注入的场景(如上传进度、后台轮询结果);- 区域必须先行存在:live region 必须在内容插入之前就存在于 DOM 中,之后动态添加的
aria-live元素不被所有读屏器可靠识别——这一点在仓库源码实现中得到了印证(见[仓库源码实证](#仓库源码实证appsweb 中的屏幕阅读器播报工具)一节)。
React Toast 组件实现
规则文档给出了完整可运行的 Toast 组件,核心是根据消息类型在 alert/status 之间切换角色与优先级,并处理了自动消失与键盘可达性:
import { useEffect, useRef } from 'react'
type NotificationType = 'success' | 'error' | 'warning' | 'info'
interface ToastProps {
message: string
type: NotificationType
duration?: number
onDismiss: () => void
}
export function Toast({
message,
type,
duration = 5000,
onDismiss
}: ToastProps) {
const toastRef = useRef<HTMLDivElement>(null)
// Auto-dismiss after duration
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(onDismiss, duration)
return () => clearTimeout(timer)
}
}, [duration, onDismiss])
// Focus toast for keyboard users
useEffect(() => {
toastRef.current?.focus()
}, [])
const isError = type === 'error' || type === 'warning'
return (
<div
ref={toastRef}
role={isError ? 'alert' : 'status'}
aria-live={isError ? 'assertive' : 'polite'}
aria-atomic="true"
tabIndex={-1}
className={`toast toast--${type}`}
>
<span className="toast__icon" aria-hidden="true">
{type === 'success' && '✓'}
{type === 'error' && '✕'}
{type === 'warning' && '⚠'}
{type === 'info' && 'ℹ'}
</span>
<span className="toast__message">{message}</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss notification"
className="toast__dismiss"
>
×
</button>
</div>
)
}
逐点解析实现中每个无障碍细节的意图:
role/aria-live按isError二选一:error与warning视为紧急,走alert+assertive立即打断;success与info走status+polite排队播报。这与选择表一一对应;tabIndex={-1}+ 挂载后focus():Toast 进入程序化焦点,键盘用户能直接操作它的关闭按钮;aria-hidden="true"图标:✓、✕这类装饰符号只服务视觉用户,必须对读屏隐藏,否则会播报出无意义的"勾"、"叉";- 关闭按钮必须有
aria-label="Dismiss notification":按钮内只有一个×字符,没有可读文本,程序化名称是键盘与读屏用户唯一的操作入口; duration默认 5000ms,duration <= 0时不自动消失——为后续"错误不自动关闭"策略留了接口。
Toast 容器与 Provider
单条 Toast 之上,规则文档提供了带 Context 的容器方案。它同时渲染两样东西:可视的 Toast 列表和一个隐藏的读屏公告区:
import { createContext, useContext, useState, useCallback } from 'react'
interface Notification {
id: string
message: string
type: NotificationType
duration?: number
}
interface ToastContextType {
addToast: (notification: Omit<Notification, 'id'>) => void
removeToast: (id: string) => void
}
const ToastContext = createContext<ToastContextType | null>(null)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Notification[]>([])
const addToast = useCallback((notification: Omit<Notification, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9)
setToasts(prev => [...prev, { ...notification, id }])
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
return (
<ToastContext.Provider value={{ addToast, removeToast }}>
{children}
{/* Toast container with live region */}
<div
className="toast-container"
aria-label="Notifications"
>
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
duration={toast.duration}
onDismiss={() => removeToast(toast.id)}
/>
))}
</div>
{/* Screen reader announcement region */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{toasts.length > 0 && toasts[toasts.length - 1].message}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const context = useContext(ToastContext)
if (!context) throw new Error('useToast must be used within ToastProvider')
return context
}
这个结构值得注意的设计取舍:
- 持久化的
sr-only公告区与可见 Toast 分离:可见 Toast 自身已有role="alert"/"status",而容器级公告区作为"兜底通道"始终存在于 DOM(对应前文的"区域必须先行存在"原则),且固定为polite——即便最新一条是错误,兜底通道也不做 assertive 打断,避免双重打断; useToast在 Provider 外直接抛错:让"忘记包 Provider"成为立即暴露的运行时错误,而不是静默失效的读屏静默。
使用示例:成功与错误的时长策略
规则文档给出的 SaveButton 用法演示了两类消息的关键差异——错误不自动消失:
function SaveButton() {
const { addToast } = useToast()
const handleSave = async () => {
try {
await saveData()
addToast({
message: 'Changes saved successfully',
type: 'success',
duration: 3000
})
} catch (error) {
addToast({
message: 'Failed to save changes. Please try again.',
type: 'error',
duration: 0 // Don't auto-dismiss errors
})
}
}
return <button onClick={handleSave}>Save</button>
}
错误消息 duration: 0 意味着用户有无限时间阅读并决定下一步;而成功消息 3 秒即走。这是"展示时间足够被读完"这条 Check 项的直接落地。
内联通知与进度通知
内联通知
内联(嵌入页面流、非浮层)通知的组件与 Toast 同构,角色切换逻辑一致,并支持可选标题与可关闭:
interface InlineNotificationProps {
type: 'error' | 'warning' | 'success' | 'info'
title?: string
children: React.ReactNode
dismissible?: boolean
onDismiss?: () => void
}
export function InlineNotification({
type,
title,
children,
dismissible = false,
onDismiss
}: InlineNotificationProps) {
const isUrgent = type === 'error' || type === 'warning'
return (
<div
role={isUrgent ? 'alert' : 'status'}
aria-live={isUrgent ? 'assertive' : 'polite'}
className={`notification notification--${type}`}
>
{title && (
<strong className="notification__title">{title}</strong>
)}
<div className="notification__content">{children}</div>
{dismissible && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="notification__dismiss"
>
×
</button>
)}
</div>
)
}
进度通知
进度类消息用 role="status" + aria-live="polite" 播报语义化文本,视觉进度条则整体对读屏隐藏,并辅以 aria-busy 告知容器处于忙碌状态:
function UploadProgress({ progress, fileName }: { progress: number; fileName: string }) {
return (
<div
role="status"
aria-live="polite"
aria-busy={progress < 100}
className="upload-progress"
>
<span className="sr-only">
Uploading {fileName}: {progress}% complete
</span>
<div aria-hidden="true">
<span>{fileName}</span>
<progress value={progress} max="100" />
<span>{progress}%</span>
</div>
</div>
)
}
注意 sr-only 里写的是人类可读的完整句子("Uploading report.pdf: 45% complete")而非裸露数字——这正是 aria-atomic 与完整语句配合的价值:每次更新读屏都能播报一个自洽的完整状态。
展示时长策略
规则文档给出的时长指导表:
| 通知类型 | 建议时长 |
|---|---|
| 成功消息 | 3–5 秒 |
| 信息/状态 | 5–7 秒 |
| 警告 | 8–10 秒或手动关闭 |
| 错误 | 不自动消失(仅手动关闭) |
配套的常量映射:
const DURATION_MAP = {
success: 3000,
info: 5000,
warning: 8000,
error: 0, // No auto-dismiss
} as const
时长策略与 aria-live 优先级是两条正交但需协同的轴:assertive 消息若同时 3 秒消失,读屏用户刚被打断却读不完内容;错误消息"不自动消失 + 手动关闭"的组合保证了"通知持久化足够时间"这条 Quick Reference 项在最高严重级别上依然成立。
样式要点:sr-only 与动效偏好
规则文档的样式部分包含 Toast 容器布局、四类颜色变体(success/error/warning/info 各自的背景与左侧色条)与关闭按钮样式,其中对无障碍真正起决定作用的是两处:
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
.sr-only是"视觉隐藏 ≠ 读屏隐藏"的标准做法:元素被压缩到 1px 并裁剪,但依然保留在可访问性树中。切勿用display: none或visibility: hidden隐藏公告区——那样读屏同样不可见;prefers-reduced-motion: reduce时禁用入场动画:入场动画(slideIn0.3s)对前庭功能障碍用户可能不适,尊重系统级动效偏好是通知组件的基本义务。
容器布局与动画部分(供完整实现参考):
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease-out;
}
.toast--success { background: #d4edda; border-left: 4px solid #28a745; }
.toast--error { background: #f8d7da; border-left: 4px solid #dc3545; }
.toast--warning { background: #fff3cd; border-left: 4px solid #ffc107; }
.toast--info { background: #d1ecf1; border-left: 4px solid #17a2b8; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
仓库源码实证:apps/web 中的屏幕阅读器播报工具
规则讲"通知应如何声明",而本仓库自己在 apps/web/lib/accessibility/screen-reader.ts 中实现了两个与之直接对应的运行时工具,是规则落地到真实代码的印证。
announce():一次性临时播报
announce() 的实现在每次调用时动态创建一个 live region、插入消息、1 秒后移除:
export function announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
const announcement = document.createElement('div')
announcement.setAttribute('role', 'status')
announcement.setAttribute('aria-live', priority)
announcement.setAttribute('aria-atomic', 'true')
announcement.className = 'sr-only'
announcement.textContent = message
document.body.appendChild(announcement)
setTimeout(() => {
document.body.removeChild(announcement)
}, 1000)
}
从源码结构看,它逐属性复现了规则的最小范式:role="status" + aria-live(默认 polite,可传 assertive)+ aria-atomic="true" + sr-only 类名。这也解释了一个常见疑问——为什么可以"动态创建" live region:region 必须先于内容变更存在,而这里正是先插入空壳(textContent = message 紧随其后)再读屏感知变化,符合时序要求。
createLiveRegion():持久区域与"重播同一条消息"的技巧
createLiveRegion() 创建一次、复用多次的持久公告区,返回 announce/destroy 两个句柄:
export function createLiveRegion(priority: 'polite' | 'assertive' = 'polite'): {
announce: (message: string) => void
destroy: () => void
} {
const region = document.createElement('div')
region.setAttribute('role', 'status')
region.setAttribute('aria-live', priority)
region.setAttribute('aria-atomic', 'true')
region.className = 'sr-only'
document.body.appendChild(region)
return {
announce: (message: string) => {
region.textContent = ''
void region.offsetHeight
region.textContent = message
},
destroy: () => {
document.body.removeChild(region)
}
}
}
其中 announce 的三行逻辑值得细看:先清空 → 强制同步 reflow(void region.offsetHeight)→ 再写入新消息。这是因为屏幕阅读器只在区域内容"发生变化"时播报,连续两次写入相同文本不会触发第二次播报;插入一次真实的 DOM 读取(offsetHeight 是经典的 reflow 触发器)确保"清空"被提交后,新内容被视为一次全新变更,从而可靠重播。这一技巧是规则文档"live region 容器"范式的运行时增强,两者可以互相参照阅读。
验证清单
规则文档定义的 Verification 六步,是上线前可执行的验收流程:
- 启用屏幕阅读器并触发各类通知;
- 确认播报发生在恰当的时机(polite 等待、assertive 立即);
- 测试键盘关闭(Escape 键);
- 确认通知不会消失过快;
- 验证关闭后的焦点管理(焦点不应"掉"进不可达状态);
- 在不同屏幕阅读器上测试(NVDA、VoiceOver、JAWS)。
自动化工具(Nu Html Checker 等,规则 frontmatter 中列出的 resource)能发现缺失 role/aria-live 的标记,但播报时机、打断感与焦点行为必须靠真实读屏器人工验证——这也是该仓库 screen-reader-testing 规则反复强调的分界线。
小结
accessible-notifications 规则给出的是一套完整闭环的决策链:语义分级(polite/assertive 与 status/alert 选择表)→ 标记落地(aria-live + aria-atomic + sr-only 容器)→ 交互完整性(程序化焦点、带 aria-label 的关闭按钮、装饰图标 aria-hidden)→ 时长策略(错误不自动消失的 DURATION_MAP)→ 样式约束(.sr-only 视觉隐藏而非可访问性树移除、prefers-reduced-motion 降级)→ 读屏器验证。仓库中的 screen-reader.ts 则从运行时侧印证了"区域先行存在"与"变更才播报"两个底层机制。代码评审时记住 SKILL.md 的最终要求:检查的是模板与服务端渲染出来的最终 HTML,并在问题报告中精确到元素、属性与路由。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00