# prop-for-that > Expose runtime state that JavaScript knows but CSS can't see, as batched, diffed > CSS custom properties. CSS then computes and reacts to it via `var()` (continuous > values) or `@container style()` (discrete values), with no JS in the animation path. prop-for-that reads browser/runtime state (pointer, viewport, element size, visibility, slider values, network, battery, sensors, and more) and writes it into `--live-*` (reactive) and `--const-*` (write-once) custom properties. A single `requestAnimationFrame` writer coalesces all updates and only calls `setProperty` when a value actually changed. Zero runtime dependencies, tree-shakeable, TypeScript. ## Entry points - `prop-for-that`: the core API (`configure`, `register`, `unregister`, `propsFor`, `unbind`, `reset`). - `prop-for-that/auto`: side-effecting and fully declarative. Attaches **nothing** by default — it scans the DOM for `[data-props-for="key1 key2"]` and binds exactly those sources (globals included, declared on `` → writes to `:root`), watching the DOM with a `MutationObserver`. An element can add `data-props-to=""` to write its properties on the nearest matching **ancestor** instead (the element is still what's observed) — editing that attribute rebinds every key on the element against the new target. Plugin sources are **loaded on demand**: the first time a key needs one, its chunk is dynamically imported and registered, then the binding attaches (no `registerPlugins()`, no separate plugins import). Because it lazy-loads via dynamic `import()`, use it as a module — `
…
``` Imperative: ```js import { propsFor } from 'prop-for-that' propsFor(['viewport']) // core global → :root propsFor(el, ['size', 'visibility']) // writes element vars to el ``` A plugin (pointer is one now — register before use; `auto` does this for you): ```js import { register, propsFor } from 'prop-for-that' import { pointer, battery } from 'prop-for-that/plugins' register(pointer) propsFor(['pointer']) // writes pointer vars to :root register(battery); propsFor(['battery']) ``` Container-bound range (so siblings can read the value): ```js // the source finds the inner but writes the vars on the wrapper propsFor(document.querySelector('.meter'), ['range']) ``` Hoisting to an ancestor (the general form — works for every element source): ```html
…
``` ```js propsFor(img, ['img', 'size'], { to: 'figure' }) // or { to: someElement } ``` Continuous reaction with `var()`: ```css .card { transform: rotateY(calc((var(--live-pointer-x-ratio) - 0.5) * 16deg)); } ``` Discrete reaction with a style query: ```css @container style(--live-online: 0) { .banner { display: block; } } @container style(--live-value: 100) { .badge { color: var(--accent); } } ``` ## Gotchas (read before writing CSS) - **Custom properties inherit downward only.** An `element`-scoped source writes its vars on the bound element, so only its *descendants* see them — and since `@container style()` matches an ancestor, the element can't style-query its own values either. Fix it by **hoisting**: `propsFor(el, keys, { to: 'figure' })` or `data-props-to="figure"` observes `el` but writes on the ancestor, so siblings (and `el` itself) can read and style-query it. The complementary trick: `range`, `field`, `select`, `media`, `img`, `img-color`, `video-color`, `color-input` also accept being bound to a *container* — they find the inner element and write on the container. Keep hoists to one binding per key per target (two elements hoisting the same key to one ancestor overwrite each other; it warns). - **Pick the right consumer.** Continuous numbers go through `var()` + `calc()`. Discrete/boolean/integer values use `@container style()`, which matches on **equality only** (range comparisons like `style(--x < 5)` are not stable yet). Emit booleans/tiers when you need thresholds. - **`visibility` is binary + latched, not a ratio.** It writes `--live-visible` and `--const-has-entered`. There is no continuous visible-ratio and no scroll-position source: use native `animation-timeline: scroll()` / `view()` for continuous scroll-driven effects. - **Values are unitless.** Multiply by `1px`, `1deg`, `100%`, etc. in CSS. - **Configure prefixes before attaching.** `configure({ livePrefix, constPrefix })` must run before any `propsFor()`. - **Typed properties are opt-in.** `configure({ typed: true })` (before attaching) registers each written `--live-*` with `@property` so it interpolates (consumers add `transition`/`@keyframes`) and resolves to `0` before a value arrives. With the `auto` entry, `` is the markup equivalent (boolean, read once on load; typing is global per `@property` name, so no per-key subset). It is a one-way door (no unregister), uses `inherits: true`, is feature-detected, and skips names already registered. Sources can declare per-property `props: { name: { syntax, initial, inherits } }` (default `` / `0` / inherited); consumers can set initials with `configure({ typed: true, defaults: { 'pointer-x-ratio': 0.5 } })` (keyed by local name). - **Permission-gated plugins** (`orientation`, `motion`, `geo`) and unsupported APIs feature-detect and no-op; registering them is always safe. On iOS some need a user-gesture permission grant before values arrive. `cpuPressure` is Chromium-only and needs a secure context + the `compute-pressure` Permissions Policy; it seeds `--live-cpu-pressure: 0` where supported, writes nothing where not. - **Global writes land in an adopted stylesheet, not `` inline style.** Root `--live-*` go into one constructable, adopted `:root {}` rule (element-scoped writes stay inline; the `head` entry still writes `--const-*` inline for first paint). Keeps the DevTools Styles panel usable under per-frame churn; `var()`/`calc()` consumption is identical (still inherits). Reading `documentElement.style` directly won't show them — read computed style. Falls back to inline where constructable stylesheets are unsupported. - **Continuously-sampling element sources pause off screen.** Under both the imperative API and `auto`, a *gated* `element`-scoped source runs only while its element intersects the viewport (via the shared `IntersectionObserver`); off screen its work (listeners/observers/timers) is torn down and its last-written values freeze in place, resuming + re-seeding on re-entry. This covers the sources that do continuous/heavy work: `size`, `truncated`, `pointer-local`, `media`, `img-color`, `video-color`. `global` sources and `:root` bindings are never gated. Opt out with `gate: false`: `visibility` does (it must keep observing to *report* visibility), and so do the **event-driven** sources `range`, `field`, `field-state`, `form-state`, `select`, `color-input`, `img` — they only write on user interaction or `load` (impossible off screen), so they seed once at bind and run ungated rather than paying for an `IntersectionObserver` subscription that saves nothing. `random` is ungated for a stronger reason: re-entry re-runs `start`, which would re-roll its write-once values. - **Freeze or throttle the loop.** The whole loop also freezes automatically while the tab is hidden (`document.hidden`). `pause()` / `resume()` stop and restart sampling+flushing (values hold steady; bindings stay attached). `configure({ liveHz: 30 })` caps the rate to reduce mutations/recalc, throttling samplers (`fps`, `scroll-velocity`) too. ## Links - [Documentation](https://prop-for-that.netlify.app/docsite/): full guides, concepts, and API. - [Plugin & source reference](https://prop-for-that.netlify.app/docsite/reference/plugins/): every source, key, and property. - [Live demos](https://prop-for-that.netlify.app/): interactive examples of each source. - [Under consideration](https://prop-for-that.netlify.app/docsite/considered/): deferred/proposed sources and voting. - [Repository](https://github.com/argyleink/prop-for-that): source, issues, changelog, contributing.