Chrome 152 · CSS

Playstate

Every reactive audio player on the web keeps a small script whose only job is to listen for play and pause and copy that into a class name. Chrome 152 makes the state a selector. Add :has() and it stops being about the element: the whole page can react to the media. Nothing on this page runs. Press play and watch it change anyway.

01 · the stage

Press play

The disc spins, the bars rise, the border warms and the word changes. All of it is CSS matching on the <audio> element below it. Hit mute and the colour drains out. Scrub and the border flashes cool.

Your browser does not have these selectors. You are looking at the static fallback, which is the honest thing to show: the disc sits still and dull, the bars hold a frozen pattern, and the readout says IDLE no matter what the player is doing. The pseudo-classes are Chrome 152, which was in beta when this page was written. Everything below still explains the feature, and the readout in section 04 lists exactly what you do and do not have.

IDLEPLAYING state via :has(audio:playing)
:playing :paused :muted :seeking :buffering :stalled :volume-locked

The transport is the browser's own controls, and that is deliberate. Styling from state is free now; causing state still needs script. A page with no JavaScript can react to a player perfectly and cannot build one, so this shows the half that actually changed.

02 · the code

What replaced the script

The before is not complicated, it is just load-bearing: a listener pair, a class toggle, and a bug the first time someone mutes from the OS instead of your button.

Before

// and one of these per state, forever
const a = document.querySelector('audio');
a.addEventListener('play',  () =>
  root.classList.add('is-playing'));
a.addEventListener('pause', () =>
  root.classList.remove('is-playing'));
a.addEventListener('volumechange', () =>
  root.classList.toggle('is-muted', a.muted));
// …then remember to remove them all

After

.stage:has(audio:playing) .disc {
  animation-play-state: running;
}
.stage:has(audio:muted) .bars i {
  background: linear-gradient(
    180deg, var(--dim), transparent);
}
/* no listeners, nothing to tear down,
   and OS-level mute is picked up too */
03 · the two we will not fake

Buffering and stalled

Both are real selectors in 152 and both are wired into the stylesheet above. Neither will ever fire on this page, because the audio is an inline data: URI: it is already in memory, so it never waits on a network and never enters a wait state.

01

We could have faked it

A timed animation on the buffering chip would look identical to the real thing on a screenshot. It would also be the one lie that makes every other number on this page worth nothing.

02

So the rules ship unused

The :buffering and :stalled rules are in the stylesheet, doing nothing. Point the same CSS at a slow network source and they light up with no other change.

03

That is the real win

A buffering spinner used to mean listening for waiting and playing and racing them against a timeout. Now it is a selector that cannot desynchronize from the element.

04 · live capability readout

What your browser actually has

Detected with @supports selector(…), so this readout needs no JavaScript either. FALLBACK means that section is showing you the degraded version.

LiveFallback :playing / :pausedThe disc spins and the bars run while audio plays. Fallback: everything static, status reads IDLE.
LiveFallback :buffering / :stalledWired up, deliberately not demonstrated. An inline data: URI never waits on a network, so these cannot fire here even when supported.
LiveFallback :volume-lockedMatches when the device refuses programmatic volume, which is most iOS hardware. Rarely true on desktop.
LiveFallback :has()The part that lets media state escape the element and style the page. Baseline since 2023.
LiveFallback relative alpha() colorsTints derived from one token via rgb(from … / alpha(8%)). Fallback: hand-written rgba() of the same colour, visually identical.
LiveFallback animation-timeline: view()Scroll reveals and the top progress bar. Fallback: everything visible and static.
05 · receipt

Verify it yourself

> source verification
[GREP] grep -c "<script" index.html → 1 (JSON-LD metadata only, no executable code)
[RUNTIME] 0 lines of JavaScript execute on this page
[CSS] :playing · :paused · :seeking · :buffering · :stalled · :muted · :volume-locked
[BRIDGE] :has() lifts element state to the page. This is what makes it more than element styling
[COLOR] relative alpha() with a literal rgba() fallback per token
[NETWORK] 0 outbound requests · audio inlined as data: URI · works offline
[AUDIO] 12s · 8kHz · 8-bit mono · synthesized offline · 96KB raw / 128KB base64
[A11Y] native controls kept (keyboard + screen reader) · decorative visuals aria-hidden · reduced-motion honoured
[BETA] Chrome 152 was in BETA when this shipped. On stable and on non-Chromium you get the fallback.
[UNTESTED] :buffering and :stalled are wired but cannot fire from a data: URI. Not demonstrated, not faked.
Back to the Labs →