页面右上角那个「60 秒后重新获取」,看得见的人盯着数字往下掉;用读屏的人呢?多半只在第一次聚焦时听到一句「剩余 60 秒」,然后就安静了。等他想再确认一次,得手动把焦点挪回去——在抢购、答题、验证码这种场景里,这基本等于没给信息。
问题不在读屏软件,在我们对 role="timer" 的理解。这个属性只声明了语义,没有声明行为。

role=”timer” 不负责播报
直觉上我们觉得「都说了这是 timer,读屏就该每秒念一次」。但 ARIA 规范里,timer 属于 live region 家族,而它隐含的 aria-live 值是 off——也就是说,只挂一个 role,读屏没有任何「监听」义务。它只在初次渲染或焦点进入时念一次,之后你改多少次,都是静音。
顺手把这一家子的默认值理一遍,差别就清楚了:
|
role |
隐含 aria-live |
适合干的事 |
|---|---|---|
|
|
assertive(打断) |
表单报错、支付失败 |
|
|
polite(排队) |
「保存成功」这类状态提示 |
|
|
polite |
聊天记录、日志追加 |
|
|
off |
滚动字幕,一般不播报 |
|
|
off |
倒计时 / 计时器,必须自己补 aria-live |
所以正确组合是:role="timer" 管语义(告诉辅助技术「这是一段计时信息」),aria-live="polite" 管行为(「内容变了就排队念一遍」)。两个缺一不可。
html反例 · 只念一次
<!-- 只有语义,没有行为:读屏聚焦时念一次就没了 -->
<div role="timer">剩余 <span id="cd">60</span> 秒</div>
<!-- 完整写法:语义 + 播报行为 + 整体朗读 + 只听文本变化 -->
<div role="timer" aria-live="polite" aria-atomic="true" aria-relevant="text">
剩余 <span id="cd">60</span> 秒
</div>
三个属性各管一摊,别照抄不理解
aria-live="polite"
变更时排队播报,等当前这句念完再插进来,不打断用户正在做的操作。倒计时用 polite;assertive 是留给「验证码错误」「余额不足」这种必须立刻打断的话,拿它念秒数,用户会被你吵到关掉读屏。
aria-atomic="true"
把容器当整体念一遍。默认是 false,读屏只念变化的那一段——你把「剩余」和「秒」写在外面、只改中间那个数字,读屏可能只蹦出一个「3」,用户听到的是一串孤零零的数字。设成 true,「剩余 3 秒」才是完整的一句话。
aria-relevant="text"
只关心文本内容变化。默认值是 additions text(节点新增 + 文本变化),意味着你插一个装饰图标、换一次 class,都可能被当成「新增了点东西」念出来。倒计时本来就是纯文本更新,显式写 text 最干净。另外别碰 removals,否则删节点时读屏会念「移除……」,非常吵。
还有个不写在例子里的前提:带 aria-live 的容器,必须在页面初次渲染时就存在于 DOM 里。运行时才 appendChild 进去的容器,很多读屏(NVDA + Firefox 上尤其明显)不认第一次变化——大家管这叫「live region 没被注册上」。先渲染一个空容器,之后只往里填文本,才是稳的。
真正的难点:别把读屏念成机关枪
一秒刷一次,对眼睛很自然;对耳朵,60 秒就是 60 句话排进队列。polite 的语义是「等这句念完再念下一句」,用户想动一下界面,就被这一串数字拖住,NVDA 上还能听出明显的延迟感。
我常用的做法是拆成两条通道:眼睛看的一条,耳朵听的一条。
-
视觉层:照旧每秒刷新,该怎么显示怎么显示;
-
听觉层:一个只对读屏可见的 live region,只在关键节点更新——开始、60、30、10,最后 5 秒逐秒报。
html双通道结构
<!-- 视觉层:每秒刷,读屏直接跳过 -->
<div class="resend" aria-hidden="true">
<span id="cd-num">60</span> 秒后可重新发送
</div>
<!-- 听觉层:只在关键节点更新 -->
<div id="cd-live" class="sr-only"
role="timer" aria-live="polite" aria-atomic="true" aria-relevant="text">剩余 60 秒</div>
<!-- 需要打断时走这里,比如「时间到」 -->
<div id="cd-alert" class="sr-only" role="alert"></div>
视觉层加 aria-hidden="true" 是必要的:否则用户聚焦到那一行时,数字被念一次,live region 又念一次,同一个人说两遍话。
javascript关键节点才出声
const MILESTONES = new Set([60, 30, 10, 5, 4, 3, 2, 1]);
function tick() {
// 眼睛:每秒都刷
numEl.textContent = String(left).padStart(2, '0');
// 耳朵:只在这些点上说一句
if (MILESTONES.has(left)) announce(format(left));
}
这里有个坑要提前说:.sr-only 只能用「裁剪」的方式隐藏,不能用 display:none 或 visibility:hidden。不渲染的节点,读屏根本不会去监听它,live region 直接失效。
css只给读屏看的隐藏方式
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
更新 DOM 的手势要对
-
只改
textContent。别innerHTML,更别整块outerHTML重写。重建节点对读屏来说是「删除 + 新增」,轻则重复播报,重则监听关系丢失,后边彻底不念了——这类 bug 最难查,因为它只在某些读屏上出现。 -
数字别零填充给读屏。视觉上
05好看,读屏可能念成「零五」。要好看就在视觉层补零,听觉层老老实实写「5 秒」。 -
超过一分钟要说人话。丢一个
90过去,用户得自己换算;写成「1 分 30 秒」才叫同步。 -
容器初始就在 DOM 里,前面说过,再说一遍,因为它是最常见的原因。
暂停、归零和 aria-busy
暂停时把 aria-busy="true" 挂到 live region 上,读屏会暂缓播报这段时间的变更,恢复时再去掉。不然用户点暂停后,还可能被一堆已经过期的数字追着念。
归零那一下值得单独打断一次:切 aria-live 的值在部分读屏上不生效,更稳的是另起一个 role="alert" 的节点专门说「时间到」。
再补一条容易被忘的:页面切到后台时(visibilitychange)先停表,回来再走。否则用户切回来的瞬间,读屏很可能一次性补念十几条。
javascript暂停 / 恢复 / 归零
function announce(text, urgent) {
const box = urgent ? alertEl : liveEl;
box.textContent = text; // 只换文本,不碰结构
}
function pause() {
clearInterval(timer); timer = null;
liveEl.setAttribute('aria-busy', 'true'); // 暂停期间先别念
announce('倒计时已暂停', true);
}
function resume() {
liveEl.removeAttribute('aria-busy');
announce(format(left));
timer = setInterval(tick, 1000);
}
document.addEventListener('visibilitychange', () => {
if (timer) document.hidden ? pause() : resume();
});
一份能直接抄的完整实现
下面是短信验证码 60 秒倒计时的完整版,存成 .html 打开就能跑,用读屏软件听一下差别。
htmlcountdown.html · 完整示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>可访问的短信验证码倒计时</title>
<style>
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.resend{font:16px/1.6 system-ui,sans-serif;color:#555}
.resend b{font-size:20px;color:#b45309}
</style>
</head>
<body>
<button id="send">发送验证码</button>
<!-- 视觉层:每秒刷新,对读屏隐藏 -->
<p class="resend" aria-hidden="true" hidden id="cd-view">
<b id="cd-num">60</b> 秒后可重新发送
</p>
<!-- 听觉层:关键节点才播报 -->
<div id="cd-live" class="sr-only" role="timer"
aria-live="polite" aria-atomic="true" aria-relevant="text"></div>
<!-- 打断层:时间到 -->
<div id="cd-alert" class="sr-only" role="alert"></div>
<script>
const TOTAL = 60;
const MILESTONES = new Set([60, 30, 10, 5, 4, 3, 2, 1]);
const viewEl = document.getElementById('cd-view');
const numEl = document.getElementById('cd-num');
const liveEl = document.getElementById('cd-live');
const alertEl = document.getElementById('cd-alert');
const sendBtn = document.getElementById('send');
let left = TOTAL, timer = null;
// 听觉层用自然语言,别丢一个裸数字过去
function format(n){
if (n < 60) return '剩余 ' + n + ' 秒';
const m = Math.floor(n / 60), s = n % 60;
return s ? '剩余 ' + m + ' 分 ' + s + ' 秒' : '剩余 ' + m + ' 分';
}
function announce(text, urgent){
const box = urgent ? alertEl : liveEl;
box.textContent = ''; // 清空再写,确保内容确实「变了」
box.textContent = text;
}
function tick(){
numEl.textContent = String(left).padStart(2, '0'); // 眼睛:补零好看
if (MILESTONES.has(left)) announce(format(left)); // 耳朵:关键节点出声
if (left === 0) { stop(); return; }
left -= 1;
}
function start(){
left = TOTAL;
viewEl.hidden = false;
sendBtn.disabled = true;
announce(format(TOTAL)); // 开局先说一句,别让用户干等
timer = setInterval(tick, 1000);
}
function stop(){
clearInterval(timer); timer = null;
viewEl.hidden = true;
sendBtn.disabled = false;
liveEl.setAttribute('aria-busy', 'true');
announce('时间到,可以重新发送验证码', true);
}
sendBtn.addEventListener('click', start);
document.addEventListener('visibilitychange', () => {
if (timer) document.hidden ? pause() : resume();
});
function pause(){
clearInterval(timer); timer = null;
liveEl.setAttribute('aria-busy', 'true');
announce('倒计时已暂停', true);
}
function resume(){
liveEl.removeAttribute('aria-busy');
announce(format(left));
timer = setInterval(tick, 1000);
}
</script>
</body>
</html>
实测清单与收尾核对表
纸上跑通不算数,live region 的实现差异是真存在的。至少过一遍这几组:
|
组合 |
重点验证 |
常见状况 |
|---|---|---|
|
NVDA + Firefox |
动态插入的容器是否被注册 |
后插的 live region 第一次变化不播报 |
|
NVDA + Chrome |
polite 队列堆积 |
高频更新时播报明显滞后于画面 |
|
JAWS + Chrome |
|
对非默认值支持偏保守,建议用 |
|
VoiceOver + Safari(macOS) |
切换标签页后回来 |
偶发漏播,别依赖焦点触发播报 |
|
VoiceOver + Safari(iOS) |
播报频率 |
节点太密容易卡,关键节点要更稀疏 |
|
TalkBack + Chrome(Android) |
|
false 下表现不一,统一设 true 更省心 |
提交前对着过一遍
-
role="timer"和aria-live是同时写的,不是二选一 -
aria-atomic="true"已设,整句播报而不是单个数字 -
aria-relevant="text"已设,装饰性变更不会插话 -
容器初始就在 DOM 里,不是用到才插
-
更新只改
textContent,没重建节点 -
播报做了节流,眼睛和耳朵分开喂
-
视觉层
aria-hidden,同一个数字不会被念两遍 -
暂停有
aria-busy,归零有 alert 打断 -
真机 + 读屏实测过,不是只看 MDN
无障碍不是给标签打勾:role="timer" 写上了,清单上就多一个√,但用户听到的还是 60 秒的沉默。真正要对齐的是信息量——你眼睛看到数字在掉,他的耳朵也该知道还剩多久。

