API
All functions are exported from prop-for-that.
import { configure, register, unregister, isRegistered, propsFor, unbind, reset, pause, resume } from 'prop-for-that'configure(opts)
Section titled “configure(opts)”Override the property prefixes or the global root element. Call this once, before any propsFor() calls.
function configure(opts: Partial<Config>): void
interface Config { livePrefix: string // default: '--live-' constPrefix: string // default: '--const-' root: HTMLElement // default: document.documentElement (:root) typed: boolean // default: false; register --live-* with @property defaults?: Record<string, string | number> // typed initial values, by local name liveHz?: number // default: unset (every frame); cap loop sampling+flush rate randomSeed?: number // default: unset (Math.random()); seed the `random` plugin}Set typed: true to register written --live-* properties with @property, making them interpolatable and giving them a guaranteed initial value. Pass defaults (keyed by a source’s local name, e.g. 'pointer-x-ratio') to choose those initial values. See Typed properties. With the auto entry, <html data-props-typed> is the markup equivalent of typed: true.
Set randomSeed to make the random plugin deterministic: each element’s rolls are derived from the seed plus its position in the DOM instead of Math.random(), so the same markup renders the same “random” layout on every load. It’s read when a source starts, so it can differ between two propsFor() calls. With the auto entry, <html data-props-seed="42"> is the markup equivalent (a non-numeric value is ignored, with a warning).
Set liveHz to cap how often the shared loop samples and flushes — e.g. 30 coalesces writes to at most 30/sec. Fewer custom-property mutations means less style recalc and a calmer DevTools Styles panel, at the cost of update smoothness. It throttles the whole loop, so per-frame samplers like fps and scroll-velocity measure at this rate too.
Example
configure({ livePrefix: '--pft-live-', constPrefix: '--pft-const-',})register(source)
Section titled “register(source)”Add a custom or plugin source to the registry so it can be used by key via propsFor() or data-props-for.
function register(source: Source): void
interface Source { key: SourceKey // unique identifier used in propsFor/data-props-for scope: 'global' | 'element' gate?: boolean // element sources are viewport-gated by default; set false to opt out (visibility does) props?: Record<string, PropSpec> // optional @property typings per local name, applied when typed start(ctx: SourceContext): Disposer}
interface SourceContext { target: HTMLElement config: Config write(localName: string, value: string | number, cadence?: 'live' | 'const'): void}
type Disposer = () => voidExample
import { register, propsFor } from 'prop-for-that'import { scrollVelocity } from 'prop-for-that/plugins'
register(scrollVelocity)propsFor(['scroll-velocity'])unregister(key)
Section titled “unregister(key)”Remove a source from the registry by key, so it can no longer be attached. Already-active bindings of that key are unaffected; tear those down with their disposer or unbind().
function unregister(key: SourceKey): voidExample
import { unregister } from 'prop-for-that'
unregister('scroll-velocity')isRegistered(key)
Section titled “isRegistered(key)”Whether a source key is currently registered — either a core source or one added via register(). Handy for guarding a register() call or for tooling. (The auto entry uses it to decide whether a data-props-for key needs its plugin chunk loaded.)
function isRegistered(key: SourceKey): booleanpropsFor(...)
Section titled “propsFor(...)”The single entry point for attaching sources. It’s global (writes to the configured root, :root by default) unless the first argument is a Node, a NodeList, or an array of elements, in which case the sources attach to that element (or to each element). Returns a disposer that tears down exactly what that call started and removes the custom properties it wrote.
function propsFor(keys: string[]): Disposerfunction propsFor(target: Element | NodeList | Element[], keys: string[], opts?: PropsForOptions): Disposer
interface PropsForOptions { to?: Element | string // write the properties on this ancestor instead}Global (writes to :root):
import { register, propsFor } from 'prop-for-that'import { pointer } from 'prop-for-that/plugins'
register(pointer) // pointer is an opt-in plugin; viewport is coreconst dispose = propsFor(['viewport', 'pointer'])
// later, stop both sources and remove the properties they wrotedispose()Per element:
// an element's id is already a global in JS — no query selector needed// (here, <input id="slider" type="range">)const dispose = propsFor(slider, ['range'])
// later, clean updispose()Across a NodeList or array (attaches to each element):
const dispose = propsFor(document.querySelectorAll('[data-reveal]'), ['visibility'])
// one disposer tears down every element this call toucheddispose()Hoisted to an ancestor (opts.to):
// observe the <img>, but write --live-loaded / --live-natural-w on the <figure>const dispose = propsFor(img, ['img'], { to: 'figure' })
// an explicit element works toopropsFor(img, ['size'], { to: img.parentElement })Custom properties inherit downward only, and @container style() matches against an ancestor — so an element can neither share nor style-query the values written on itself. to observes the bound element but writes its properties on an ancestor, which is what lets a <figcaption> (or a skeleton overlay, or the <img> itself) react. It takes a selector, resolved once at bind time with el.closest(), or an element. The markup equivalent is data-props-to. See Style queries.
Three things to know:
- No match warns and degrades. If the selector matches no ancestor, the properties are written on the element itself (a
console.warnsays so) — the effect degrades rather than disappearing. - Globals ignore it.
toon aglobal-scoped source warns and is ignored; globals already write to a shared target. - One binding per key per target. Two elements hoisting the same key onto one ancestor overwrite each other’s values, and disposing either removes the property. It warns in that case; give each hoist its own target.
Re-attaching a key that’s already active on the same target is a no-op for that key — including a re-attach with a different to. Change the target by tearing the binding down first (unbind(el, [key])), then binding again.
unbind(target, keys?)
Section titled “unbind(target, keys?)”Detach specific source keys from an element, or all sources if keys is omitted.
function unbind(target: HTMLElement, keys?: string[]): voidExamples
// Remove just the 'range' sourceunbind(slider, ['range'])
// Remove all sources from this elementunbind(slider)The element is removed from the internal bindings map once all its sources are detached.
reset()
Section titled “reset()”Tear down everything: every active binding (global and per element) plus the shared observers and listeners. Use it to fully clean up, for example between tests or before a hot reload.
function reset(): voidExample
import { reset } from 'prop-for-that'
reset()pause() / resume()
Section titled “pause() / resume()”Freeze and unfreeze the shared frame loop. While paused, no source samples and no writes flush, so every live value holds its current state — useful for inspecting them in DevTools without the per-frame churn. Bindings stay attached; resume() picks sampling back up and flushes anything queued meanwhile. Both are idempotent.
Backgrounded tabs are paused automatically (the loop freezes on document.hidden), so you don’t need to wire that up yourself — reach for pause()/resume() for your own freeze-on-demand cases.
function pause(): voidfunction resume(): voidExample
import { pause, resume } from 'prop-for-that'
// a "freeze" toggle — handy for inspecting live values in DevToolslet frozen = falsefreezeButton.addEventListener('click', () => { frozen = !frozen frozen ? pause() : resume()})