轮播每 3 秒自己翻页:键盘停不下来,读屏也不知道换了页

一个自动播放的轮播图:每 3 秒自己翻一页。想让它停下------焦点落在「下一张」上,按 Enter 只是再翻一页;松手,它继续走。页面上根本没有暂停键,切换时也没有任何播报。

读屏用户更被动:内容被换掉了,没有任何播报。这篇把「自动播放」当成一个可访问性问题来修,而不是一个动画参数。

先量一遍:它到底能不能停

对照页用 1 秒间隔(把 3 秒压缩,方便数),4 屏、带箭头和圆点。0.2.25 的实测:

指标 0.2.25
暂停控件 0 个
第一个 Tab 停靠点 上一张箭头
焦点在箭头上等 3 秒 索引 3 → 2(继续走)
aria-live 无
aria-roledescription 无
指示点 aria-current 0 个
系统「减少动态效果」下 仍然自动翻页

焦点在轮播内部、用户正在操作,它照样每 3 秒换一次内容。WCAG 2.2.2 的要求很直接:自动更新、且持续时间超过 5 秒的内容,必须给用户一个暂停、停止或隐藏的机制。箭头和圆点解决的是「手动切换」,不是「停下来」。

这类问题在 demo 里看不出来:画面一直在动,看起来「很正常」。只有把焦点放进去、数 3 秒,才会发现没有任何东西能让它停。

怎么量的

同一份 4 屏轮播,分别加载 npm 上的 0.2.25 与本地构建的 0.2.26,间隔压到 1 秒。状态行每 120ms 打印:索引、aria-live、暂停按钮与文案、aria-pressed、aria-current 数量、当前焦点、matchMedia 是否命中。

每组操作先读状态、断言符合预期,再截图。两版跑同一套 14 条断言:0.2.25 组按「预期坏」断言,0.2.26 组按「应该好」断言,全部通过。

源码:定时器从挂载跑到卸载

原来的自动播放只有两个函数:

ts 复制代码
function startAutoplay() {
  if (props.autoplay <= 0 || timer !== null) return;
  timer = setInterval(() => { ... next(); }, props.autoplay);
}

onMounted(startAutoplay);
onBeforeUnmount(stopAutoplay);

没有「暂停」状态,没有系统偏好判断,也没有页面可见性判断。setInterval 从挂载一直跑到卸载,唯一的停止时机是拖动时临时停一下、松手再启动。

prefers-reduced-motion 也管不到它:那个媒体查询影响的是 CSS 过渡,而翻页是 JS 定时器驱动的。减少动效只关掉动画,不关掉计时器------这是第一个反直觉点。

修法一:暂停按钮必须是键盘第一站

先加一个真实的 <button>,aria-pressed 表达状态,文案在「暂停自动播放 / 继续自动播放」之间切换:

html 复制代码
<button class="rn-carousel__pause" :aria-pressed="userPaused"
        :aria-label="userPaused ? t('carousel.play') : t('carousel.pause')">

关键在 DOM 顺序:它排在箭头和圆点之前。这样按一次 Tab 就落在暂停键上,而不是先经过「上一张」「下一张」「第 1 张」......才找到它。

实测第一个 Tab 停靠点:0.2.25 是 rn-carousel__arrow--prev,0.2.26 是 rn-carousel__pause。

按钮默认 opacity: 0,悬停、键盘聚焦或已暂停时出现。不可见但仍可聚焦,是这里刻意保留的:鼠标用户看到的是干净的画面,键盘用户 Tab 过来时按钮会显形并获得焦点环。

为什么默认透明

一直显示会破坏画面;完全隐藏又找不到。折中是 opacity: 0 加三种显形条件:悬停、:focus-within、已暂停。键盘 Tab 到它时 :focus-within 生效,按钮出现并获得焦点环;已暂停时常驻,提醒用户「现在可以继续」。透明不等于不可点,但鼠标用户只在需要时看到它。

为什么不做「hover 自动暂停」

常见做法是鼠标移入就暂停。问题是它只对鼠标成立:键盘用户把焦点放在轮播里,定时器照走;触屏用户根本没有 hover。它改善体验,但不构成 WCAG 要求的「机制」。显式按钮才是。

为什么不做「focus 自动暂停」

另一个常见做法是焦点进入就暂停。实测会遇到一个死结:用户点「继续自动播放」时,焦点还在这个按钮上,focus 暂停逻辑立刻又把播放停掉------按钮看起来失灵。

显式按钮没有这个问题:暂停与否只由 userPaused 决定,与焦点无关。

修法二:aria-live 要跟着播放状态切换

轮播的内容变化要不要播报?答案取决于它是否在自动播放:

  • 播放中 :aria-live="off"。否则每 3 秒打断一次读屏,用户什么都听不完;
  • 暂停后 :aria-live="polite"。此时翻页由用户触发,播报是必要的反馈。
html 复制代码
<div class="rn-carousel__track" :aria-live="isAutoPlaying ? 'off' : 'polite'">

实测:0.2.25 的 track 没有 aria-live;0.2.26 播放中是 off,点暂停后变 polite。

这里我一开始想反了------以为「有 live region 总比没有好」。真实规则是「自动变化时闭嘴,用户操作时说话」,常开反而更吵。轮播里如果嵌了视频或动画,同样的切换逻辑也适用,判断依据始终是「这次变化是谁触发的」。

修法三:轮播本身的语义

<div> 包几屏内容,读屏软件只知道这里有一堆文字。补三样:

html 复制代码
<div role="region" aria-roledescription="carousel" :aria-label="t('carousel.label')">
  • role="region":成为一个可跳转的地标;
  • aria-roledescription="carousel":读屏会念「轮播」而不是「区域」;
  • aria-label:给它一个名字(新增 locale 键,中文「轮播图」,英文 Carousel)。

指示点补 aria-current="true",读屏能知道「当前是第几张」。实测 aria-current 命中的点数:0 → 1。

修法四:减少动效与后台标签页

两处收尾:

  1. 挂载时读 matchMedia("(prefers-reduced-motion: reduce)"),命中就默认暂停,按钮显示「继续自动播放」。用户仍可手动继续------尊重偏好,但不剥夺选择。
  2. 监听 visibilitychange:页面隐藏时 clearInterval,回到前台再续。后台标签页里每 3 秒翻一次页没有意义,还白耗电。

实测「减少动态效果」模拟(CDP 强制 reduce)等 2.6 秒:0.2.25 索引走到 2 ,0.2.26 停在 0,按钮文案是「继续自动播放」。

我以为是 A,实测是 B

第一处 :我以为「有箭头、有圆点」就等于可操作。实测键盘第一站是上一张箭头,按 Enter 只翻一页;等 3 秒,索引从 3 走到 2------没有任何控件能让它停。可操作不等于可停止。

第二处 :我以为 aria-live 加上就好。实测常开会每 3 秒打断读屏;正确做法是播放中 off、暂停后 polite。同一个属性,两种状态。

第三处:我以为「焦点进入就暂停」是最稳的方案。实测点「继续」时焦点还在按钮上,会立刻被自己的 focus 逻辑停掉,按钮看起来坏了。显式按钮把「用户意图」和「焦点位置」解耦,才没有这个死结。

第四处 :我以为 prefers-reduced-motion 能顺带关掉自动播放。实测它只影响 CSS 过渡,setInterval 完全不受影响;必须单独读 matchMedia 并停表。

换成另一种实现会怎样

方案 能停住吗 问题
只有箭头/圆点(原方案) 不能 只能手动翻,不能停止自动更新
hover 暂停 鼠标能,键盘不能 不构成 WCAG 机制;触屏无 hover
focus 暂停 键盘能 点「继续」时焦点仍在按钮,立刻又被停
显式暂停按钮(现方案) 所有输入方式都能 多一个控件,需要处理与自动播放的状态同步
干脆不自动播放 不需要停 失去「自动展示」的产品诉求

第三行和第四行的差别,本质是「用焦点位置推断意图」和「让用户显式表达意图」。前者在组合交互里总有边界情况,后者没有。

边界:单屏、不循环、autoplay=0

  • 只有一屏时定时器仍会启动,但每次 tick 直接 return------白跑;可以在启动前判断 slideCount <= 1 就不建表。
  • loop=false 走到末屏后停表,按钮文案仍是「暂停自动播放」但计时器已不存在;再播放需要先回到前面的屏。
  • autoplay=0 不渲染暂停按钮------没有自动播放,就没有暂停语义。
  • 拖动期间停表,松手后按 isAutoPlaying 恢复;用户已暂停时拖动不会偷偷启动。

这些边界都不需要新 API,但每一个都可能让「暂停」在某种状态下名存实亡。

结果

0.2.26 已发布:暂停/继续按钮(DOM 第一站)、aria-live 双态、轮播语义与 aria-current、减少动效默认暂停、页面隐藏停表。

  • 单测 923 → 926(暂停语义、Tab 停靠点与 aria-current、减少动效,共三条用例)
  • 真机断言 14/14 通过:暂停后 3 秒索引 3→3;继续后恢复推进;0.2.25 对照组 3 秒内 3→2
  • 文档站新增的示例可直接看到右下角的暂停键

结论

自动播放不是「加个 setInterval」那么简单,它是一个持续占用用户注意力的行为。三条规则,按重要性排序:

  1. 必须有一个显式的暂停机制,且要能被键盘第一时间够到------不是 hover,也不是靠焦点推断;
  2. aria-live 跟着播放状态走 :播放中 off,暂停后 polite;
  3. prefers-reduced-motion 要单独判断,它管不到 JS 定时器;页面隐藏时也要停表。

最容易被忽略的是第 1 条里的「第一时间」:暂停键放在箭头和圆点后面,键盘用户仍要按 4 次 Tab 才能停------功能有了,但没解决问题。第 3 条也常被跳过:prefers-reduced-motion 看起来像个 CSS 开关,实际管不到 JS 计时器,必须单独读一次 matchMedia 再决定要不要建表。


组件解剖 · 系列目录

  • Table:虚拟滚动的三个硬约束
  • Radio / RadioGroup:ARIA 与方向键选中
  • Transfer:三态复选框的三个 bug
  • DatePicker:42 格日期网格的四个边界
  • Select:可搜索下拉的键盘、ARIA 与滚动
  • Carousel(本篇):自动播放的暂停机制与播报时机

在线示例:ui.ruanno.top/components/... 源码:gitee.com/ruanno-top/...

相关推荐
1-1=041 分钟前
Vue 3 模板语
前端·javascript·vue.js
Java小卷2 小时前
面向 Schema 编程,Vue 3 页面级渲染引擎!轻松玩转各场景 Validation!
vue.js·低代码
FungLeo5 小时前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装
FungLeo7 小时前
成为全栈·Vue 管理后台篇·Vue Router如何守住多角色后台边界
前端·javascript·vue.js·rbac·vue router·路由守卫·前端权限
FungLeo7 小时前
成为全栈·Vue 管理后台篇·分页只显示第一页:从接口total 定位到前端修复
前端·vue.js·状态模式·分页·naive ui·服务端分页·前端问题排查
FungLeo8 小时前
成为全栈·Vue管理后台篇·仪表盘不是几张卡片:组合统计接口与角色视图
前端·javascript·vue.js·rbac·仪表盘·管理后台·tanstack query
ruanno_top8 小时前
可搜索的下拉框,键盘按不动:焦点进了下拉,事件还挂在触发器上
vue.js
FungLeo9 小时前
成为全栈·Vue 管理后台篇·Vue Query:服务端数据缓存、query key与变更失效
vue.js·数据获取·前端缓存·前端状态管理·tanstack query·vue query
北城笑笑9 小时前
Vue 109 ,从零搭建 Vue3 + Electron 工程:生态、安全桥接与打包选型( Vue + Electron )
前端·javascript·vue.js·安全·electron·vue