npm i prop-for-that

What JS knows, now CSS knows.

Runtime state for CSS.
Now there's a Prop For That.

View on GitHub Read the docs

00 / auto

Quick start: just an attribute

import 'prop-for-that/auto' once, then tag any element with data-props-for and list the props you want.

 × 

JS
import 'prop-for-that/auto'
HTML
<div data-props-for="size" style="resize: both"></div>
CSS
.box::after {
  counter-reset: w calc(var(--live-w)) h calc(var(--live-h));
  content: counter(w) ' × ' counter(h);
}

01 / pointer

Track the pointer

Bind pointer-local to an element and it gets its own --live-local-pointer-x-ratio / --live-local-pointer-y-ratio props.

local

move your
pointer
tilt your
device

the panel follows, then rests

Motion blocked · needs a mouse here

HTML
<div id="card" data-props-for="pointer-local"></div>
CSS
.tilt-card {
  --rx: calc((.5 - var(--live-local-pointer-y-ratio)) * 16deg);
  --ry: calc((var(--live-local-pointer-x-ratio) - .5) * 16deg);
  transform: rotateX(var(--rx)) rotateY(var(--ry));
}

02 / scroll

Reveal once, then stay

Triggered, not linked. --const-has-entered latches once a panel is fully in view, so each reveals once and stays. Scroll back up: a view() timeline would reverse, this won’t.

Each step develops in as it enters — lift, unblur, fade — then holds.

--live-visible lights this card only while it’s wholly on screen.

One element, two signals — live toggles, entered latches.

in view entered

Scroll back up: in view dims, entered stays. No reset.

03 / range

One value, many readers

Bind one source to the container; the gauge ring, the number, and the slider all read the same value. At either end, a @container style() query flips a min / max state that var() can’t express.

HTML
<div id="meter" data-props-for="range">
  <input type="range" min="0" max="100" value="42">
</div>
CSS
.gauge__num::after {
  counter-reset: v calc(var(--live-value));
  content: counter(v);
}
.gauge__arc {
  background: conic-gradient(var(--accent)
    calc(var(--live-value-pct) * 360deg), var(--line) 0);
}
Discrete state: a style query, not var()
@container style(--live-value: 100) {
  .gauge__num { color: var(--max-tint); }
  .gauge__flag::after { content: 'max'; }
}

04 / style query

Discrete state, whole rules

var() interpolates a number; it can’t turn 3 into the word “high” or switch a rule on at one exact value. @container style() can: each level lights a different block of CSS, zero JS branches.

HTML
<div id="level" data-props-for="range">
  <input type="range" min="0" max="4" step="1" value="2">
</div>
CSS
@container style(--live-value: 0) {
  .level__word::after { content: 'Off'; }
}
@container style(--live-value: 4) {
  .level__word::after { content: 'Max'; }
  .level__core { --tier-h: 25; }
}

05 / field

Length, live as you type

The limits stay in HTML (minlength, maxlength); CSS reads the live length against them to fill the meter, count up, and name the state. No keystroke handler.

HTML
<div id="note" class="field" data-props-for="field" style="--min: 12; --max: 120">
  <textarea minlength="12" maxlength="120"></textarea>
</div>
CSS
.field__meter i {
  inline-size: calc(var(--live-length) / var(--max) * 100%);
}
@container style(--live-length: 120) {
  .field__word::after { content: 'max reached'; }
}

06 / trig

Yes, CSS can do trigonometry

Each pupil finds the cursor with atan2(), then rides there on cos() and sin(). The trigonometry is all in the stylesheet; JS never computes an angle.

HTML
<div class="eye" data-props-for="pointer-local"></div>
CSS
.eye {
  --a: atan2(
    calc(var(--live-local-pointer-y-ratio) - .5),
    calc(var(--live-local-pointer-x-ratio) - .5));
}
.eye__pupil {
  translate:
    calc(cos(var(--a)) * 1.1rem)
    calc(sin(var(--a)) * 1.1rem);
}

07 / clock

A clock, ticking in pure CSS

The clock source ticks --live-seconds once a second; the hands rotate by calc() and the readout is a CSS counter. The only JavaScript is one setInterval.

HTML
<div class="clock" data-props-for="clock"></div>
CSS
.clock__hand--sec {
  rotate: calc(var(--live-seconds) * 6deg);
}
.clock__hand--hour {
  rotate: calc(var(--live-hours) * 30deg
    + var(--live-minutes) * .5deg);
}

08 / globals

Ambient device state

The quiet globals: online, link speed, battery, frame rate. Each writes on :root for CSS to read. Toggle offline in DevTools to watch it flip. (Battery and Network are Chromium-only.)

connection

navigator.onLine

effective type

 Mbps ·  ms rtt

Chromium only

battery

Chromium only

frame rate

HTML
<div class="tiles" data-props-for="online network battery fps"></div>
CSS
@container style(--live-online: 0) {
  .tile--online { color: var(--limit); }
  .tile__online::after { content: 'offline'; }
}
.tile__gauge i {
  inline-size: calc(var(--live-battery-level) * 100%);
}

09 / velocity

Scrolling with momentum

--live-scroll-velocity is signed, and eases back to zero when you stop. Flick the page: the bars shear and slide with your speed, then settle.

HTML
<div class="momentum" data-props-for="scroll-velocity"></div>
CSS
.momentum__stack span {
  transform:
    translateX(calc(var(--live-scroll-velocity) * var(--m) * 1px))
    skewX(calc(var(--live-scroll-velocity) * -.4deg));
}

10 / size

A card that knows its own shape

Drag the corner: a ResizeObserver feeds --live-aspect, and a branch-free calc() flips the badge from portrait to landscape at aspect 1. No media query.

 × 

HTML
<div id="sizer" data-props-for="size" style="resize: both; overflow: auto"></div>
CSS
.sizer {
  --landscape: clamp(0, calc((var(--live-aspect) - 1) * 100), 1);
}
.sizer__land { opacity: var(--landscape); }
.sizer__por { opacity: calc(1 - var(--landscape)); }

11 / media

A progress ring, straight from playback

A conic-gradient ring fills from --live-progress as the clip plays. Typed as an @property, the value interpolates, so the ring sweeps smoothly between updates.

 / 

HTML
<div id="player" data-props-for="media">
  <video src="clip.mp4" muted loop autoplay></video>
</div>
The setup: typed props become interpolatable
configure({ typed: true }) // register --live-* as @property
CSS
.player { transition: --live-progress .25s linear; }

.player__ring {
  background: conic-gradient(var(--accent)
    calc(var(--live-progress) * 360deg), var(--line) 0);
}

12 / ambient

UI that saves your battery

Below 20% battery, a @container style() strips the animation and heavy paint to save power. Flip the switch to simulate it. (Battery Status is Chromium-only.)

now playing

CSS
.ambient {
  --low: clamp(0, round(up, calc(.2 - var(--live-battery-level, 1)), 1), 1);
}
@container style(--low: 1) {
  .ambient__card { animation: none; }
  .ambient__state::after { content: 'low battery'; }
}

13 / drag

Drag, with no drag handler

:active is the interaction gate; while you hold a token its translate is computed straight from --live-local-pointer-x-ratio / --live-local-pointer-y-ratio and the board’s size. Let go and it springs home — JS never does a drag calculation.

drag me & me me too
HTML
<div id="dragboard" data-props-for="pointer-local size">
  <span class="drag-token"></span>
</div>
CSS
.drag-token:active {
  translate:
    calc((var(--live-local-pointer-x-ratio) - var(--hx)) * var(--live-w) * 1px)
    calc((var(--live-local-pointer-y-ratio) - var(--hy)) * var(--live-h) * 1px);
  transition: none; /* follow while held */
}

14 / form state

The form state CSS can’t see

field-state tracks the interaction history pseudo-classes can’t — --live-dirty, --live-touched, --live-changed, --live-submitted, per input. form-state aggregates the whole form — --live-valid-count, --live-all-valid, --live-completion — to drive the progress bar and gate Submit. Type then delete back: dirty stays, changed clears. Empty a required field: completion drops, the gate closes. The required plan <select> (select--live-value-num) joins that gate, and the colour picker (color-input--live-color) re-themes the whole form — live, in CSS.

HTML
<form data-props-for="form-state">
  <input name="name" required data-props-for="field-state">
  <select name="plan" required data-props-for="select"></select>
  <input type="color" data-props-for="color-input">
</form>
CSS
.form { --accent: var(--live-color); } /* the picker re-skins it */
.seats::after { content: counter(--live-value-num) ' seats'; }
@container style(--live-all-valid: 1) {
  .submit { opacity: 1; } /* the gate opens */
}

15 / img-color

A shadow the colour of the picture

CSS can’t read an image’s pixels. img-color does — it pulls a small palette (dominant, accent, dark, light, average) into --live-img-*. The dominant tints a drop shadow that pointer-local casts away from your cursor, so the cursor is the sun; the swatches below show the rest. Pick another image and the whole palette re-reads.

HTML
<figure data-props-for="img-color">
  <img src="art.jpg">
</figure>
CSS
.sun-card { /* shadow: image colour, cast off the cursor */
  box-shadow:
    calc((.5 - var(--live-local-pointer-x-ratio)) * 8rem)
    calc((.5 - var(--live-local-pointer-y-ratio)) * 8rem) 3rem
    oklch(from var(--live-img) l c h / .6);
}

16 / video-color

A glow that watches the footage

The same idea, in motion. video-color samples a playing <video> (~4×/sec, riding requestVideoFrameCallback) for its dominant and accent colours. The dominant pours into a box-shadow glow, so the player looks backlit by whatever is on screen, easing colour to colour as the shot changes; both swatches sit below.

HTML
<figure data-props-for="video-color">
  <video src="clip.mp4" crossorigin muted loop autoplay></video>
</figure>
CSS
.player { /* backlit by the dominant, eased between samples */
  box-shadow: 0 0 6rem
    oklch(from var(--live-video) l c h / .7);
  transition: box-shadow .3s linear;
}