首页
/ Front-End-Checklist 的无障碍通知规则深度解析:ARIA Live Regions、Toast 实现与验证清单

Front-End-Checklist 的无障碍通知规则深度解析:ARIA Live Regions、Toast 实现与验证清单

2026-09-04 19:56:43作者:侯霆垣

本文基于 Front-End-Checklist 仓库中的 accessible-notifications 规则(「Make notifications accessible」),完整讲解如何用 ARIA live regions 让 Toast、内联通知与进度反馈被屏幕阅读器正确朗读:从 role="status" / role="alert" 的选择策略、React Toast 组件与 Provider 的完整实现,到停留时长规范、CSS 细节与屏幕阅读器验证清单,并结合仓库内真实源码(apps/web/lib/accessibility/screen-reader.ts 等)印证落地方式,读完即可在任意项目中写出可被辅助技术感知的通知系统。

规则概览:为什么通知必须可访问

该规则定义于 skills/accessible-notifications/references/rule.md,对应的机器可读元数据为 Priority: high · Difficulty: intermediate · Time: 25 min,与内容规则文件 accessible-notifications.mdx 的 frontmatter(priority: highdifficulty: intermediateestimatedTime: 25)一致,归类于 accessibility 与 html 两个类目、components 子类。

规则的核心论点是:没有恰当的 ARIA 属性,屏幕阅读器用户会漏掉关键通知——表单错误、成功消息、实时更新——从而对页面状态变化一无所知。Toast 与 Alert 的播报机制依赖两类东西:ARIA live regions 与恰当的 role。

配套的 SKILL.md 将该规则提炼为四条快速参考(Quick Reference),这也是后续所有代码示例的设计依据:

  • 使用 aria-live 区域播报动态内容变化
  • 根据紧急程度在 polite(等待)与 assertive(打断)之间选择
  • 确保通知停留时间足够被读完
  • 提供可见的与可编程的关闭方式

其中「Check / Fix」两条给出了可执行的审查标准:验证通知使用了 aria-live 区域、恰当的 role(alert 或 status)、且停留时间足够被阅读;修复方向是「用 role='alert'role='status'aria-live='polite''assertive' 与足够显示时长来实现通知」。

基础 HTML 实现:三种标准结构

规则给出的最小可用示例覆盖了三类场景:状态通知(polite)、告警通知(assertive)、以及供动态注入内容的 live region 容器:

<!-- 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>

三个结构各有分工:

  1. role="status":隐式带有 aria-live="polite",适合保存成功、进度等非紧急反馈,屏幕阅读器会在当前播报结束后再朗读;
  2. role="alert":隐式带有 aria-live="assertive",适合表单错误、时间敏感告警,会立即打断当前播报;
  3. 空的 live region 容器:这是最容易被忽视但最关键的模式——容器必须先于内容变化存在于 DOM 中,之后再注入文本才会触发播报。这也是为什么示例中容器是空的并带有 idaria-atomic="true"(保证内容被整体朗读而非碎片化)。仓库中另一条规则 aria-live-regions.mdx 对此有专门的反例/正例对照:带内容一起创建的 live region(❌)不会被播报,而「先挂载空区域、后改 textContent」(✅)才会。

ARIA Live Region 类型速查

属性 行为 适用场景
aria-live="polite" 等待用户停顿(当前播报结束) 状态更新、非紧急信息
aria-live="assertive" 立即打断 错误、时间敏感告警
role="status" 隐式 polite 进度、成功消息
role="alert" 隐式 assertive 错误、警告

从源码结构看,「role 隐式 live region」意味着在实际项目中两者写其一即可达到同样效果;上面 HTML 示例中同时书写 rolearia-live 是显式冗余,便于人工审查时一眼确认语义,属于防御性写法。

React Toast 组件

规则给出了一个可直接用于生产形态的 Toast 组件。关键点在于:按类型切换 rolearia-live(错误/警告 → alert/assertive,其余 → status/polite)、aria-atomic="true" 保证整条消息一次性朗读、tabIndex={-1} 配合 focus() 让键盘用户能感知到通知出现、图标 aria-hidden="true" 避免符号被读出。

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

两个值得注意的实现细节:

  • duration = 5000 是默认值,duration = 0 表示永不自动消失if (duration > 0) 分支保护了这一点),与后文「错误消息不自动消失」的时长规范对应;
  • 关闭按钮必须是有可访问名称的 <button>aria-label="Dismiss notification"),而不是无名称的 × 字符——这满足 Quick Reference 中「提供可见的与可编程的关闭方式」一条。

Toast 容器:Context、Provider 与双重播报设计

完整的 Toast 系统由 Provider 托管状态,并采用「视觉容器 + 隐藏播报区」的双层结构:

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
}

这里的设计意图可以分三层理解:

  1. useToast() 钩子通过 Context 向任意子组件暴露 addToast / removeToast,并在脱离 Provider 时显式抛错,属于快速失败(fail fast);
  2. aria-label="Notifications" 的可见容器为整组 Toast 提供了一个可定位的组名;
  3. sr-only 的兜底播报区始终挂载且最后一条消息文本持续更新——这保证了即便单个 Toast 组件因自动消失被移出 DOM,屏幕阅读器仍能从稳定存在的 live region 中获得播报(呼应「live region 必须先存在」的原则)。从源码结构看,这是一种双保险:单条 Toast 自带 role 与 aria-live,Provider 又维护一个持久区域,牺牲少量重复播报换取可靠性。

使用示例:错误永不自动消失

规则给出的 SaveButton 示例演示了完整的交互闭环,其中 duration 的取值直接体现时长策略:

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

成功消息 3 秒后自动消失;错误消息 duration: 0 永不消失,必须由用户手动关闭——因为错误信息可能需要用户阅读、采取行动后才能处理。

内联通知(Inline Notifications)

除浮层 Toast 外,规则还覆盖了表单内嵌式通知,结构与 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>
  )
}

判断逻辑与 Toast 相同(error/warning 归为 urgent),但注意一个实践差异:内联通知通常不需要主动抢焦点(它出现在文档流中,视觉位置即语义位置),这与浮层 Toast 需要 focus() 拉回注意力形成对比。

进度类通知:aria-busy 与视觉/朗读内容分离

上传进度是实时更新的典型场景。规则给出的 UploadProgress 组件展示了三个要点:aria-busy 标记进行中状态、视觉元素整体 aria-hidden、以及一个 sr-only 的完整语义句子:

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

要点解析:

  • aria-busy={progress < 100} 在未完成期间让屏幕阅读器知道该区域正在加载,避免用户把「内容在变」误解为「出错了」;
  • 视觉部分(文件名字、<progress> 元素、百分比数字)被 aria-hidden="true" 整体屏蔽,防止碎片化朗读(「upload-report.pdf」+「42%」分开读毫无意义);
  • sr-only 中的「Uploading {fileName}: {progress}% complete」是唯一会被朗读的完整句子。注意这仍是一个高频更新场景,实际项目中应配合节流(例如每 25% 或每 1 秒播报一次),否则 polite 队列会被百分比数字淹没——这一点可从 aria-live-regions.mdx 的「off:用于高频更新内容」一行推断为通用原则。

停留时长规范(Timing Guidelines)

通知类型 建议停留时长
成功消息 3–5 秒
信息/状态 5–7 秒
警告 8–10 秒或手动关闭
错误 不自动消失(仅手动)

规则同时给出了可直接使用的常量映射:

const DURATION_MAP = {
  success: 3000,
  info: 5000,
  warning: 8000,
  error: 0, // No auto-dismiss
} as const

error: 0Toast 组件中 if (duration > 0) 的守卫逻辑形成配套:0 就是「永不自动关闭」的约定值。这套数值可以直接作为团队设计系统的默认参数。

样式实现:sr-only 与 prefers-reduced-motion

完整样式如下,其中 .sr-only 是整套「视觉隐藏但可朗读」模式的物理基础,末尾的 prefers-reduced-motion 查询体现了动效的无障碍兜底:

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

.toast__dismiss {
  background: none;
  border: none;
  font-size: 1.25rem;
  cursor: pointer;
  padding: 0.25rem;
  margin-left: auto;
}

/* 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;
}

@keyframes slideIn {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
  .toast {
    animation: none;
  }
}

两个细节值得强调:

  • .sr-only 必须是「视觉裁剪」而非 display: nonedisplay: none / visibility: hidden 的元素不会进入可访问性树,live region 也就失效;1px + clip: rect(0,0,0,0) 的方案让元素保持可朗读;
  • 类型颜色除背景色外都附带 4px 左边框色(如 error 的 border-left: 4px solid #dc3545),保证色盲用户也能仅凭边框区分通知类型——这与仓库中 color-contrast 等规则一脉相承。

验证清单(Verification)

规则给出的六步验证流程,覆盖了辅助技术、键盘与焦点三个维度:

  1. 启用屏幕阅读器并触发通知;
  2. 确认播报发生在恰当的时间点(polite 不抢话、assertive 立即打断);
  3. 测试键盘关闭方式(Escape 键);
  4. 检查通知没有消失过快;
  5. 验证关闭后的焦点管理(焦点不应掉落到不可预期的位置);
  6. 使用多种屏幕阅读器交叉测试(NVDA、VoiceOver、JAWS)。

自动化工具(axe、Lighthouse 等)能发现缺失的 role / aria-live 属性,但播报时机、队列顺序与焦点行为必须人工用真实屏幕阅读器验证——这也是该规则 difficulty 为 intermediate 的原因。

仓库内的真实落地:源码印证

Front-End-Checklist 网站自身就在应用这条规则。以下实现可作为「规则如何落到 Next.js 项目」的参考:

1. 屏幕阅读器播报工具函数 —— screen-reader.ts 提供了两种模式,与规则中的「临时区域」和「持久区域」一一对应:

/** Announces a message to screen readers via a temporary ARIA live region. */
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)
}

/** Creates a persistent ARIA live region for repeated announcements; returns announce/destroy handles. */
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)
    }
  }
}

值得注意的是 createLiveRegion().announce 中的两行「清空 → 强制 reflow(void region.offsetHeight)→ 再写入」序列:可以推断其目的是规避部分屏幕阅读器对「连续写入相同文本不触发播报」的已知问题,即通过清空建立一次真实变化。

2. 错误边界默认用 role="alert" —— error-boundary.tsx 的默认 fallback 是 <div role="alert">(隐含 assertive),而 SectionErrorFallback 使用 role="alert" aria-live="polite" 的组合——渲染失败属于错误级别,但团队选择了 polite 节奏以减少打断,这是一个「按产品语境调节 politeness」的实例。

3. 页面级错误页的分级策略 —— 路由级错误页 error.tsx/error.tsx) 使用 role="alert" aria-live="polite",而整站崩溃的 global-error.tsx 升级为 role="alert" aria-live="assertive":影响范围越大,打断级别越高,与规则中「assertive 仅用于真正紧急的消息」一致。

4. 表单提交错误用 role="alert" —— waitlist-form.tsxcli-notify-form.tsx 都在 submitError 出现时渲染带 role="alert"<span>/<p>,对应规则中「表单错误属于关键通知」的结论。

5. 非紧急状态优先原生语义 —— checklist-browser.tsx<output aria-live="polite"> 播报筛选结果数量,rule-checkbox.tsxaria-live="polite"<span> 播报「Saving...」。<output> 本身就是隐式 live region 的原生元素,符合 aria-live-regions.mdx Exceptions 中「优先原生 HTML 语义而非 ARIA」的原则。

常见陷阱:不要滥用 assertive

规则末尾的警告值得原样记住:

aria-live="assertive" 留给真正紧急的消息。过度使用会不断打断屏幕阅读器的输出,破坏用户体验。

结合前文的判断矩阵,实际编码时可以用一条简单决策链:是错误/警告吗 → 是则 alert/assertive;是进度/成功/状态吗 → status/polite;高频更新吗 → 考虑 off 或节流。仓库中 global-error(assertive)与 SectionErrorFallback(polite)的分级差异,正是这条决策链的现实样本。

相关规则与延伸阅读

  • aria-live-regions.mdx:live region 基础规则,包含 politeness 级别表、off 的用途、以及「live region 必须先于内容存在于 DOM」的正反例;
  • accessible-notifications.mdx:本规则的完整 frontmatter 版本,含来源引用(MDN HTML、WHATWG HTML Living Standard)与相关规则列表;
  • SKILL.md:面向 AI Agent 的精简版规则说明(Quick Reference / Check / Fix / Explain / Code Review 五段结构);
  • 同属 html/components 子类、常与本规则一起审查的相邻规则:carousel-accessibility、accessible-tooltips、custom-element-accessibility。

适用前提:本文代码示例为框架无关的 React 18+ 语义模式,规则本身(role / aria-live / 停留时长 / 验证清单)对任意技术栈的模板、服务端渲染 HTML 与共享组件均适用;SKILL.md 中明确指出应审查「最终面向浏览器的标记,而非仅源框架的抽象」,即 SSR 场景下要检查渲染产物。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384