@schalkneethling/css-media-pseudo-polyfill
v1.0.12
Published
A CSS polyfill for media pseudo-classes (:playing, :paused, :seeking, :buffering, :stalled, :muted)
Maintainers
Readme
css-media-pseudo-polyfill
A CSS polyfill for the media pseudo-classes: :playing, :paused, :seeking, :buffering, :stalled, and :muted.
These pseudo-classes allow styling based on the playback, loading, and sound state of <audio> and <video> elements. Browser support is still incomplete — this polyfill detects which pseudo-classes the browser does not support and provides equivalent behavior via class selectors.
How it works
The polyfill runs in four stages:
Feature detection — Each of the 7 media pseudo-classes is tested individually via
CSS.supports('selector(:name)'). Only unsupported pseudo-classes are polyfilled. This allows partial support (e.g., Safari may support:playingbut not:buffering).CSS rewriting — Inline
<style>elements are parsed with css-tree. For each rule containing a target pseudo-class, a class-based equivalent is injected as a sibling rule immediately after the original in the AST (e.g.,video:playing { ... }is followed byvideo.media-pseudo-polyfill-playing { ... }). The browser skips the rule it doesn't understand and applies the class-based fallback — a natural progressive enhancement pair.<link>stylesheet rewriting — Same-origin<link rel="stylesheet">elements are fetched via the Fetch API and rewritten using the same text-based approach as inline<style>elements. The rewritten CSS is injected as a sibling<style>element and the original<link>is disabled. This avoids the CSSOM approach where browsers silently drop rules containing unrecognised pseudo-class selectors before the polyfill can process them. Cross-origin stylesheets are detected via origin comparison and skipped.Element observation — Media elements are discovered via
querySelectorAlland aMutationObserver. Event listeners are attached to each element to track state changes. On every relevant event, the element's state is recomputed and the corresponding polyfill classes are toggled.
Entry points
| Export | Description |
| --------------- | ------------------------------------------------------- |
| "." (default) | Auto-applies the polyfill on DOMContentLoaded |
| "./fn" | Exports the polyfill() function for manual invocation |
The "./fn" entry point is useful when you need to run the polyfill earlier (e.g., from a synchronous <script> in <head>) to minimize the flash of unstyled content (FOUC).
Default entry point — auto-applies on DOMContentLoaded:
import "@schalkneethling/css-media-pseudo-polyfill";"./fn" entry point — call polyfill() manually, e.g. from a synchronous <script> in <head> to minimize FOUC:
<script type="module">
import { polyfill } from "@schalkneethling/css-media-pseudo-polyfill/fn";
polyfill();
</script>Or from a module bundler:
import { polyfill } from "@schalkneethling/css-media-pseudo-polyfill/fn";
polyfill();Spec references
The pseudo-class definitions and their DOM conditions come from the WHATWG HTML spec:
| Pseudo-class | DOM condition |
| ---------------- | -------------------------------------------------------------------------------- |
| :playing | paused === false |
| :paused | paused === true |
| :seeking | seeking === true |
| :buffering | !paused && networkState === NETWORK_LOADING && readyState <= HAVE_CURRENT_DATA |
| :stalled | matches :buffering AND the internal "is currently stalled" flag is true |
| :muted | muted === true |
| :volume-locked | Not polyfillable (no DOM surface) |
The NETWORK_LOADING, HAVE_CURRENT_DATA, and other constants are defined on the HTMLMediaElement interface as const unsigned short values. They are available as both static and instance properties.
Design decisions
Per-pseudo-class detection
Rather than a single feature check, each pseudo-class is tested individually. Safari has partial support — it may implement some pseudo-classes but not others. Per-pseudo-class detection allows the polyfill to skip already-supported pseudo-classes and only rewrite and manage the unsupported ones. If CSS.supports is unavailable or throws, the pseudo-class is treated as unsupported.
Immediate-sibling injection for cascade preservation
For each rule containing a target pseudo-class, the polyfill inserts a class-based equivalent as a sibling rule immediately after the original in the AST. The original stylesheet is left untouched. This produces pairs like:
video:playing {
outline: 0.25rem solid green;
}
video.media-pseudo-polyfill-playing {
outline: 0.25rem solid green;
}Two alternative approaches were considered and rejected:
Clone-and-disable (clone the entire AST, disable the original stylesheet): unnecessarily complex. Because the polyfill class is only present when the state is active, the injected rule is inert when the state doesn't apply — it cannot interfere with other rules regardless of source order. This is fundamentally different from attribute-based polyfills (e.g., container queries) where rewritten selectors match unconditionally.
Extract-and-append (extract only rewritten rules, append to end): incorrect. Moving rules out of their
@layer,@media, or@supportscontext would break the author's cascade intent.
Immediate-sibling injection keeps each rewritten rule inside the same block as its original, preserving @layer, @media, and @supports nesting with no special logic.
Specificity-neutral substitution
The polyfill replaces PseudoClassSelector nodes (specificity 0,1,0) with ClassSelector nodes (also 0,1,0). This substitution is specificity-neutral in all contexts — including inside :is() (which uses the most specific argument's specificity), :where() (which zeroes everything), and :has() (which contributes the argument's specificity). No :where() wrapping is needed.
:volume-locked handling
:volume-locked cannot be polyfilled because the "volume locked" flag is a user-agent-level boolean with no DOM surface. The polyfill handles it as follows to prevent broken stylesheets:
- Lone selector (
video:volume-locked { ... }): the entire rule is removed - In a selector list (
video:playing, video:volume-locked { ... }): the:volume-lockedbranch is pruned; the:playingbranch is rewritten normally - Inside
:is()/:where(): the:volume-lockedargument is removed from the list - Inside
:not(): rewritten to.media-pseudo-polyfill-volume-locked(matches everything, since the class is never set — consistent behavior)
Pure state computation with externally managed stalled flag
The computeStates() function is pure — it reads properties from an HTMLMediaElement and returns a Set<string> of active states. The "is currently stalled" flag is passed in as a parameter rather than being computed internally because this flag is not directly observable from DOM properties. It must be tracked via the formal state machine defined in the HTML spec:
- Set to
truewhen thestalledevent fires (browser's ~3 second stall timeout expired) - Reset to
falsewhenprogress,emptied, orloadstartfires
This separation keeps computeStates() easily testable with plain objects.
WeakMap for per-element state
Per-element state (event handler reference and stalled flag) is stored in a WeakMap<HTMLMediaElement, ElementState>. The MutationObserver callback explicitly cleans up when elements are removed from the DOM. The WeakMap acts as a safety net — if cleanup is missed (e.g., observer disconnected, edge case in DOM reparenting), the garbage collector can still reclaim the element and its associated state. A regular Map would hold a strong reference to the element key, preventing collection. On pages with many dynamic video elements (e.g., YouTube feed with preview hovers), this prevents memory leaks.
Single event handler per element
Media events do not bubble, so each element requires direct addEventListener calls. Rather than creating 14 separate handler functions per element, a single handler is created and registered for all 14 event types. The handler uses event.type in a switch statement to handle special cases (stalled flag transitions) before recomputing state. This means 14 registrations per element (unavoidable) but only 1 function object allocated per element — on a page with 20 videos, that is 20 function objects instead of 280.
Bind-once guard
Before attaching listeners, the polyfill checks whether the element is already tracked in the WeakMap. This prevents duplicate bindings from rapid DOM reparenting, where the MutationObserver may report the same element in both removedNodes and addedNodes in a single batch.
Fetch-based rewriting for <link> stylesheets
Same-origin <link rel="stylesheet"> elements are rewritten by fetching the CSS text and running it through the same rewriteCss() function used for inline <style> elements. The rewritten output is injected as a <style> element immediately after the original <link>, which is then disabled.
An earlier approach used the CSSOM API (sheet.cssRules + insertRule()), which avoids an extra network request. However, this has a fundamental flaw: browsers silently drop rules containing unrecognised pseudo-class selectors during CSS parsing. By the time the polyfill walks the CSSOM, the rules it needs to rewrite have already been discarded. The fetch-based approach accesses the raw CSS text before browser parsing, ensuring all rules are available for rewriting.
Cross-origin stylesheets are detected via new URL(href).origin comparison against window.location.origin and skipped. This covers both relative paths (resolved to absolute by the browser) and explicit absolute URLs to external CDNs.
Known limitations
:volume-lockedis not polyfillable. The "volume locked" flag has no DOM surface. The polyfill removes:volume-lockedselectors from rewritten stylesheets and never sets the corresponding class.Class selectors can match non-media elements. Native
:playingonly matches<audio>and<video>elements. The polyfilled.media-pseudo-polyfill-playingclass has no such restriction. In practice this is not an issue because the polyfill only ever toggles these classes on media elements. An author would need to manually add the class to a non-media element to trigger a false positive.FOUC window. Stylesheet rewriting runs when the polyfill is invoked (at
DOMContentLoadedfor the default entry point). There is a window between first paint and polyfill initialization where pseudo-class-based styles are not applied. Use the"./fn"entry point from a synchronous<script>in<head>to minimize this gap.Cross-origin
<link>stylesheets are not rewritten. The polyfill only fetches and rewrites same-origin stylesheets. Cross-origin sheets served from external CDNs are skipped.
