Video player states without JavaScript events

Reflecting a video's state in the UI used to mean wiring up play, pause, and waiting event listeners, then toggling class names on a wrapper element. Media pseudo-classes let CSS read the state directly.

Old Event listeners
const video = document.querySelector('video');const wrap = video.closest('.player');video.addEventListener('play', () => wrap.classList.add('is-playing'));video.addEventListener('pause', () => wrap.classList.remove('is-playing'));video.addEventListener('waiting', () => wrap.classList.add('is-buffering'));video.addEventListener('playing', () => wrap.classList.remove('is-buffering'));video.addEventListener('volumechange', () => {  wrap.classList.toggle('is-muted', video.muted);});
Modern
Pseudo-classes
video:paused + .controls .play-icon { display: block; }video:playing + .controls .pause-icon { display: block; }video:buffering + .controls .spinner { display: block; }video:muted + .controls .muted-badge { display: block; }.player:has(video:playing) {  outline: 2px solid currentColor;}
Limited availability 17% global usage

This feature is not Baseline because it does not work in some of the most widely-used browsers.

Not ready for production without a fallback.

150+
15.4+

Only Safari 15.4+ and Firefox 150+ support these pseudo-classes today. Chromium is expected to ship them as part of Interop 2026. Keep a JavaScript class-toggle fallback as your primary path until then.

No event listener churn

No addEventListener for play, pause, waiting, seeking, or volumechange. The browser already knows the state.

No class toggle bookkeeping

No is-playing class to add and remove, no stale classes when the user scrubs or the tab backgrounds. The pseudo-class always reflects the real state.

Seven states to style

:playing, :paused, :seeking, :buffering, :stalled, :muted, and :volume-locked. Each is a plain selector you can combine with :has() to style parent containers.

Old Approach
addEventListener + classList
Six events to wire up
Modern Approach
:playing, :paused, :muted
Zero JavaScript
Interop 2026
Focus area
Chrome and Edge implementation expected

How it works

The old pattern was a stack of event listeners. You attached play, pause, waiting, playing, volumechange, and seeking handlers to the <video> element, then toggled class names like is-playing or is-buffering on a wrapper. Every state transition meant code.

Media pseudo-classes move that logic into the selector. video:paused .play-icon only matches when the video is paused. video:buffering .spinner only shows when the browser is waiting for more data. Combine with :has() to style ancestors: .player:has(video:playing) reaches the container without a wrapper class.

Support today is Safari 15.4 and Firefox 150. Chrome and Edge do not ship these pseudo-classes yet, but the feature is an Interop 2026 focus area, which means the four major browsers have committed to closing the gap this year. Treat this as progressive enhancement for now and keep your existing class-toggle path as the baseline.

ESC