Skip to content

Style queries

CSS consumes the properties prop-for-that writes in two ways:

  • var() interpolates on a value. Best for continuous numbers (--live-pointer-x-ratio, --live-value-pct) fed through calc().
  • @container style() applies a conditional rule when a property equals a value. Best for discrete, boolean, or enum state (--live-online, --live-visible, --const-has-entered, --live-value: 100, --live-paused, --live-battery-low, capability tiers).

Style queries are what make the discrete signals shine: a value flips whole rule blocks on and off, which var() can’t do.

A style query matches against an element’s nearest ancestor container, and for style queries every element is a container by default (no container-type needed). So:

  • Global sources write to :root, an ancestor of everything, so they’re queryable anywhere.
  • Element sources write to the bound element, so its descendants query it. (An element can’t style-query its own property, only an ancestor’s.) That’s exactly why a gauge inside the container-bound range wrapper can react.

Hoisting an element’s values to an ancestor

Section titled “Hoisting an element’s values to an ancestor”

That ancestor rule is the whole reason data-props-to (imperatively, propsFor(el, keys, { to })) exists. An <img> that writes --live-loaded on itself can’t query it, and neither can the <figcaption> next to it. Hoist the writes one level up and both can:

<figure>
<img data-props-for="img" data-props-to="figure" src="art.jpg">
<div class="skeleton"></div>
<figcaption>Sunset over the pier</figcaption>
</figure>
.skeleton { opacity: 0; transition: opacity 0.3s; }
/* the <figure> is the query container for everything inside it — including the
<img> that produced the value */
@container style(--live-loaded: 0) {
.skeleton { opacity: 1; } /* sibling: shimmer while it loads */
img { filter: blur(8px); } /* the source element reacts to its own state */
}
@container style(--live-broken: 1) {
figcaption::after { content: ' (image unavailable)'; }
}

The element is still the one being observed — only the writes move. See propsFor’s to option for the resolution rules (selector via closest(), no-match fallback, one binding per key per target).

import { register, propsFor } from 'prop-for-that'
import { online } from 'prop-for-that/plugins'
register(online)
propsFor(['online']) // writes --live-online (1/0) to :root
.offline-banner { display: none; }
/* :root is an ancestor container, so this works anywhere on the page */
@container style(--live-online: 0) {
.offline-banner { display: block; }
body { filter: grayscale(1); }
}

Example 2: an element milestone (slider at max)

Section titled “Example 2: an element milestone (slider at max)”
<script type="module">import 'prop-for-that/auto'</script>
<!-- auto binds it from the attribute; descendants of #meter inherit --live-value -->
<div id="meter" data-props-for="range"></div>
/* .gauge is inside #meter and inherits --live-value, so its descendants
can match the exact integer, which var()/calc() can't express */
@container style(--live-value: 100) {
.gauge__num { color: var(--accent); }
.gauge__flag::after { content: 'max'; }
}

Example 3: a capability tier (proposed capability plugin)

Section titled “Example 3: a capability tier (proposed capability plugin)”
/* ship lighter media on lite connections */
@container style(--live-delivery-mode: 1) {
.hero-video { display: none; }
.hero-poster { display: block; }
}

Style queries match on equality (style(--live-net-type: 4)), not comparisons. Range queries (style(--live-battery-level < 0.2)) aren’t stable across browsers yet.

That suits this library: emit discrete booleans and tiers from JS, and you get threshold styling that works today. The battery plugin’s --live-battery-low (1/0), or a capability tier, lets you write:

@container style(--live-battery-low: 1) {
.autoplay { animation-play-state: paused; }
}

No waiting for range queries. Pair continuous values with var(), discrete values with @container style().

Style queries for custom properties ship in Chromium 111+, Safari 18+, and Firefox 128+. Where unsupported, @container style() blocks are ignored, so treat them as progressive enhancement and keep a sensible default outside the query (as the examples above do).