配了三年自定义播放器,每次想知道视频在不在播都要靠 JS 监听事件再加 class——今天 CSS 用七个伪类把这件事彻底原生化了
写了三年自定义播放器,每次想知道视频在不在播,都要靠 JS 监听 play/pause 事件再往 wrapper 加 class——Safari 15.4 / Firefox 150 / Chrome 158 现在把这件事用一行 CSS 原生化了。
每个做过视频播放器的前端都知道这个套路:页面上放个 <video>,外面包一层 .player 的 div,再写一堆 JS:
const video = document.querySelector("video");
const wrapper = document.querySelector(".player");
video.addEventListener("play", () => wrapper.classList.add("is-playing"));
video.addEventListener("pause", () => wrapper.classList.remove("is-playing"));
CSS 再读这个 class:
.player.is-playing .play-button { opacity: 0.4; }
.player:not(.is-playing) .play-button { opacity: 1; }
状态一多,这套机制就开始漏:缓冲时 class 没及时更新,进度条拖拽时 class 和实际状态对不上,页面开了多个播放器时还要处理竞态。JS 和 CSS 之间的这座桥,是硬着头皮搭上去的。
CSS Selectors Level 4 把这座桥拆了。 浏览器早就知道视频在不在播放,现在它把这件事直接暴露给 CSS——不用 JS 中转,不用 class 同步。
一行 CSS,替代一坨 JS
:playing 和 :paused 是最基础的两个伪类,直接匹配 <video> 和 <audio> 元素的播放状态:
/* 播着的时候,按钮变淡 */
.player:has(video:playing) .play-button { opacity: 0.4; }
/* 暂停的时候,按钮高亮 */
.player:has(video:paused) .play-button { opacity: 1; }
:has() 在这里起了关键作用:外层 wrapper 能读取子元素的状态,不需要子元素自己改自己的 class。两个选择器组合起来,就是一个完整的播放状态 UI 响应——没有一行 JS。
整个伪类家族一共有七个,覆盖完整的播放状态:
/* 播放中 */
video:playing { border: 3px solid green; }
/* 暂停中 */
video:paused { border: 3px solid orange; }
/* 正在缓冲 */
video:buffering { --spinner-opacity: 1; }
/* 下载中断(卡住)*/
video:stalled { --spinner-opacity: 1; }
/* 用户在拖进度条 */
video:seeking { outline: 2px solid blue; }
/* 静音 */
video:muted { --mute-icon-opacity: 1; }
/* 音量被系统锁定(移动端常见)*/
video:volume-locked { --lock-icon-opacity: 1; }
状态和 :has() 配合起来,是最推荐的用法——外层容器读子元素状态,按钮、封面、进度条、字幕按钮都可以联动:
/* 播放时隐藏封面图 */
.player:has(video:playing) .cover { opacity: 0; pointer-events: none; }
/* 暂停且有封面时显示封面 */
.player:has(video:paused):has(.cover) .cover { opacity: 1; }
/* 缓冲时让转圈显示 */
.player:has(video:buffering, video:stalled) .spinner { opacity: 1; }
:buffering 和 :stalled 的区别
这两个状态特别容易混淆,实际行为完全不同:
:buffering:媒体在正常下载中——视频播到了还没下载的部分,播放器在等数据。此时:playing同样为 true。:stalled:下载中断了——网络断了或者 CDN 抽了。此时:playing也为 true,但视觉上应该显示「网络不太好」的提示。
/* 正常缓冲,转圈提示但不报错 */
.player:has(video:buffering) .spinner { opacity: 1; }
/* 真正卡住,显示警告 */
.player:has(video:stalled) .warning { opacity: 1; }
渐进增强写法
Safari 和 Firefox 已经支持一年多,Chrome 158 刚加入,支持率在 2026 年已经相当高。但考虑到用户群体,建议用 @supports selector() 做渐进增强:
/* 默认:按钮可点击 */
.play-button { opacity: 1; cursor: pointer; }
/* 渐进增强:有支持的浏览器才改状态 */
@supports selector(video:playing) {
.player:has(video:playing) .play-button { opacity: 0.4; cursor: default; }
.player:has(video:paused) .play-button { opacity: 1; }
.player:has(video:buffering, video:stalled) .spinner { opacity: 1; }
}
这个写法在没有支持的浏览器里完全回退到默认态——按钮始终可点击;支持的浏览器里,播放状态自动联动。
JS 还干什么
这套方案让 JS 只负责三件事:实际调用 video.play() / video.pause()、进度条拖拽的数学计算、以及 analytics 事件上报。状态同步归 CSS,JS 归交互,两边的职责终于分开了。
之前用 JS 维护的那坨 classList.add/remove 可以直接删掉——实测说明,这个分工能消除一整类「播放器状态变了但 UI 没跟上」的 bug。
三个坑
坑一:Chrome 刚支持要渐进增强。 Chrome 158(August 2026)是首批稳定版,用户 Agent 覆盖还不完整,@supports selector(video:playing) 必须加。
坑二::playing 和 :paused 不互斥于「未激活」状态。 视频加载完但用户还没点播放时,两者都不是——这是 :not(:playing):not(:paused) 的情况,别忘了处理封面显示逻辑。
坑三:只能读内嵌 <video> / <audio> 状态。 外部嵌入的 iframe 播放器(YouTube / Vimeo)不在这个范围内,还是要用各自的消息 API。
三步下一步
- 找代码:打开你项目里播放器的 JS,搜
addEventListener.*play和classList,这就是要替换的部分——删掉事件监听里的 class 操作 - 写 CSS:把
.is-playing/.is-paused/.is-buffering的 class 规则,改成:has(video:playing)/:has(video:buffering)的 CSS 规则,外层加@supports selector(video:playing) { } - 验证:用 Chrome 158+ 打开播放器,分别触发播放、暂停、缓冲、拖拽四种状态,检查按钮、封面、转圈的响应是否符合预期
Chrome 158 把媒体状态伪类加入支持后,Safari / Firefox / Chrome 三大引擎全部就位,Interop 2026 的跨引擎一致性目标正式完成一个。播了三年视频播了十年 JS 的播放器状态,终于可以彻底交给 CSS 了。
评论区
登录后可评论。