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.
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);}); 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;} media pseudos Browser Support
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.
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.
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.