Design · · 5 min read
Your video player's state is now a CSS selector
Seven pseudo-classes match audio and video by playback state, and Chromium shipped them in August 2026. What each matches, and the overlap that trips people up.
A custom video player normally keeps its own copy of the truth: a JavaScript listener for play, another for pause, another for waiting, each toggling a class so CSS can react. All of that is now a selector.
.player:has(video:paused) .play-icon { display: block; }
.player:has(video:playing) .play-icon { display: none; }Seven pseudo-classes match <audio> and <video> elements by their state. They became Baseline newly available on 28 August 2026, when Edge 152 landed three days after Chrome 152. Safari has had them since 15.4, in March 2022 — over four years earlier — which is why so few people know they exist.
The seven pseudo-classes
Definitions from Selectors Level 4, Resource State Pseudo-classes:
| Selector | Matches when the element… |
|---|---|
:playing | Is playing, including while buffering or stalled |
:paused | Is not playing — explicitly paused, or loaded and never started |
:seeking | Is seeking to a new position |
:buffering | Can't continue because it's fetching data and hasn't got enough yet |
:stalled | Has been trying to fetch data and received none for the stall timeout |
:muted | Is capable of sound but forced silent |
:volume-locked | Has its volume locked by the user agent or operating system |
They apply to the media element itself, so the selector is video:paused, not .player:paused.
The overlap that breaks naive CSS
:buffering and :stalled are subsets of :playing, not alternatives to it. The spec says it outright: "Whenever :buffering matches an element, :playing also matches the element." The same holds for :stalled.
So this does the wrong thing:
/* Broken: while buffering, both rules match */
.player:has(video:playing) .spinner { opacity: 0; }
.player:has(video:buffering) .spinner { opacity: 1; }Whichever rule wins the cascade wins always, and the spinner either never appears or never leaves. Exclude explicitly instead:
.player:has(video:playing:not(:buffering, :stalled)) .spinner { opacity: 0; }
.player:has(video:buffering, video:stalled) .spinner { opacity: 1; }The mental model that works: :playing means "the user has asked for playback and hasn't taken that back", not "pixels are currently changing". A buffering video is still playing; it just isn't showing you anything.
:paused is the true opposite of :playing, and it covers the state before anyone has pressed anything. A video that has loaded and never been started matches :paused, which is exactly what a play button needs.
A player that needs no state JavaScript
The pseudo-classes sit on the media element, and the controls sit next to it, so :has() does the work of connecting them. :has() has been Baseline widely available since June 2026, so the pairing is safe.
<div class="player">
<video src="/clip.mp4" playsinline></video>
<button class="toggle" aria-label="Play">
<svg class="icon-play">…</svg>
<svg class="icon-pause">…</svg>
</button>
<div class="spinner" aria-hidden="true"></div>
</div>/* Icon swap */
.player:has(video:paused) .icon-pause,
.player:has(video:playing) .icon-play { display: none; }
/* Loading state */
.spinner { opacity: 0; transition: opacity 150ms; }
.player:has(video:buffering, video:stalled) .spinner { opacity: 1; }
/* Dim the poster overlay once playback starts */
.player:has(video:playing) .overlay { opacity: 0; pointer-events: none; }
/* Show the controls bar whenever playback isn't running */
.player:has(video:paused) .controls { translate: 0 0; }
/* Muted badge */
.player:has(video:muted) .sound-off { display: block; }
/* Hide the volume slider entirely when the OS owns the volume */
.player:has(video:volume-locked) .volume { display: none; }That's a play/pause button, a loading spinner, a poster overlay and a volume control, with no state listeners. The JavaScript that remains is the part that causes state — a click handler calling play() or pause() — not the part that reflects it.
Why :volume-locked is worth knowing
A media element is :volume-locked when the user agent or operating system has taken control of its volume. On such an element, MDN notes that the volume "cannot be muted, un-muted, or have its volume changed via JavaScript". Setting .volume from script silently does nothing.
A volume slider in that situation isn't broken — it's meaningless, and worse, it looks broken to the person dragging it. :volume-locked lets you hide it and leave the hardware controls to do their job.
What CSS still can't tell you
These pseudo-classes report state, not values. There is no selector for:
- the current time or duration
- the scrubbed position of the playhead
- how much has buffered
- the volume level, only whether it's muted or locked
A progress bar still needs timeupdate and a custom property. Accessible labelling still needs JavaScript, because a button whose icon changes in CSS keeps whatever aria-label the markup gave it — update that in your click handler, or the screen-reader label will say "Play" throughout the film.
Browser support and fallback
At the time of writing, from browser-compat-data:
| Browser | Version | Released |
|---|---|---|
| Safari (macOS and iOS) | 15.4 | March 2022 |
| Firefox (desktop and Android) | 150 | April 2026 |
| Chrome and Chrome for Android | 152 | August 2026 |
| Edge | 152 | August 2026 |
Because support is so new in Chromium, keep the class-toggling fallback for now if the player is a core part of the product, and treat the selectors as the enhancement:
@supports selector(video:buffering) {
/* the CSS-only version */
}For a marketing page with a background video, the degradation is harmless: an unsupported browser simply never matches the rule, so the spinner stays hidden and the icon never swaps.
FAQ
Does :playing match while the video is buffering?
Yes, deliberately. :buffering and :stalled both imply :playing. Use :playing:not(:buffering, :stalled) when you mean "actually showing frames".
What's the difference between :buffering and :stalled?
Both mean the element is trying to fetch data. :stalled means it has received none for the media element stall timeout defined in the HTML specification; :buffering means it's still receiving, just not fast enough yet.
Can I style the built-in controls with these?
No. They match the media element, so they can style the element itself and, with :has(), anything else on the page. The browser's own control UI stays out of reach.
Do they work on <audio> too?
Yes. Every one of the seven is defined for both <audio> and <video>.
Sources
- Selectors Level 4, Resource State Pseudo-classes — CSS Working Group editor's draft
:playingon MDN:bufferingon MDN:volume-lockedon MDN- web-features:
media-pseudos— Baseline status and dates @mdn/browser-compat-data— per-browser version data
More to read
- Tech
Tech · · 5 min read
CSS progress() is Baseline. It is not what the name suggests
progress() returns how far one value sits between two others, as a number from 0 to 1. What it computes, the fluid typography it simplifies, and what it doesn't do.
- Design
Design · · 6 min read
Staggered animations in plain CSS, now that sibling-index() is Baseline
sibling-index() and sibling-count() became Baseline newly available in August 2026. How to stagger a list without JavaScript, and what breaks if you're careless.