← Read

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:

SelectorMatches when the element…
:playingIs playing, including while buffering or stalled
:pausedIs not playing — explicitly paused, or loaded and never started
:seekingIs seeking to a new position
:bufferingCan't continue because it's fetching data and hasn't got enough yet
:stalledHas been trying to fetch data and received none for the stall timeout
:mutedIs capable of sound but forced silent
:volume-lockedHas 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:

BrowserVersionReleased
Safari (macOS and iOS)15.4March 2022
Firefox (desktop and Android)150April 2026
Chrome and Chrome for Android152August 2026
Edge152August 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

More to read