Skip to content

Plugins

Plugins cover state beyond the four core sources: pointer position, sensors, network, battery, media, and more. They live in one tree-shakeable entry, prop-for-that/plugins, and you register only the ones you use, so nothing you don’t import reaches your bundle.

Import the plugin, register() it once, then use it by key with propsFor(), exactly like a core source:

import { register, propsFor } from 'prop-for-that'
import { battery } from 'prop-for-that/plugins'
register(battery)
propsFor(document.documentElement, ['battery'])
.indicator {
width: calc(var(--live-battery-level) * 100%);
}

Register several at once with registerPlugins(). Pass specific sources, or no arguments to register everything:

import { registerPlugins, fps, clock } from 'prop-for-that/plugins'
registerPlugins(fps, clock) // just these two
registerPlugins() // or everything

Register, then attach with propsFor([...]); they write to :root.

ImportKeyProperties
pointerpointer--live-pointer-x, --live-pointer-y (px), --live-pointer-x-ratio, --live-pointer-y-ratio (0–1 across the viewport). High-frequency (a write per pointermove), so it’s an opt-in plugin rather than a core source.
scrollVelocityscroll-velocity--live-scroll-velocity (signed px/frame, decays to 0), --live-scroll-direction (1 / -1 / 0)
onlineonline--live-online (1 / 0)
pageFocusedpage-focused--live-page-focused (1 / 0) — the tab is frontmost and its window is focused; goes 0 when the user switches to another tab, window, or app. Seeded from document.hasFocus(), driven by window focus/blur.
pageVisiblepage-visible--live-page-visible (1 / 0) — the tab is in the foreground vs hidden (backgrounded, minimized, switched away). Distinct from page-focused: a visible tab can still be unfocused. Use it to pause video/polling/expensive effects when truly hidden. Seeded from document.visibilityState, driven by visibilitychange.
navTypenav-type--const-nav-type — how the user arrived, written once as a string: navigate, reload, back_forward (history traversal / bfcache), or prerender, from performance.getEntriesByType('navigation')[0].type. Pairs with style queries — @container style(--const-nav-type: reload) { … }. Rides the writer, so it lands the frame after bind; read the same value in a <head> script if you need it before first paint.
metametaevery <meta> value JS can read but CSS can’t, written once as --const-meta-<slug> (slug = lowercased name/property, non-alphanumerics → -): <meta name="theme-color">--const-meta-theme-color, <meta property="og:image">--const-meta-og-image, etc. Use them directly — background: var(--const-meta-theme-color), background-image: url(var(--const-meta-og-image)), @container style(--const-meta-color-scheme: dark) { … }. First match wins per name; a media-conditional meta is skipped when its query doesn’t match, so the active theme-color variant is the one written. Read once at bind, so meta tags swapped in after bind aren’t tracked.
uaualow-entropy device & browser identity CSS can’t see, written once: --const-ua-platform (macos / windows / linux / android / ios / chromeos / unknown), --const-ua-browser (chrome / edge / firefox / safari / opera / samsung / unknown), --const-ua-engine (blink / gecko / webkit / unknown), --const-ua-version (browser major, 0 if unknown), --const-ua-mobile (1 / 0). Branch on the client with @container style(--const-ua-engine: webkit) { … }. Prefers UA Client Hints (navigator.userAgentData, Chromium), falls back to a best-effort userAgent sniff on Firefox / Safari. Low-entropy only — no high-entropy hints, so no fingerprinting surface beyond request headers. Also written before first paint by the head entry — see the User agent demo
networknetwork--live-net-downlink, --live-net-rtt, --live-net-save-data (1 / 0), --live-net-type (slow-2g=1 … 4g=4, else 0)
batterybattery--live-battery-level (0–1), --live-battery-charging (1 / 0)
clockclock--live-now (epoch seconds), --live-hours, --live-minutes, --live-seconds
fpsfps--live-fps (rounded)
visualViewportvisual-viewport--live-vvp-scale, --live-vvp-offset-top, --live-vvp-height
keyboardkeyboardthe soft (on-screen) keyboard’s geometry: --live-keyboard-open (1 / 0), --live-keyboard-height, --live-keyboard-width, --live-keyboard-x / -y / -right / -bottom (px, client coords) — so layout can reflow around it with padding-block-end: calc(var(--live-keyboard-height) * 1px). Uses the VirtualKeyboard API for exact geometry on Chromium/Android — it sets navigator.virtualKeyboard.overlaysContent = true while bound (so the keyboard overlays content instead of resizing the viewport) and restores it on dispose — and falls back to inferring the height from visualViewport shrink on iOS/Safari (approximate: a pinch-zoom can read as a keyboard). Seeds zeros where neither is available.
orientationorientation--live-orient-alpha, --live-orient-beta, --live-orient-gamma (degrees)
motionmotion--live-accel-x, --live-accel-y, --live-accel-z (m/s², gravity included)
geogeo--live-geo-lat, --live-geo-lng, --live-geo-accuracy (metres)
cpuPressurecpu-pressure--live-cpu-pressure (CPU Compute Pressure as an ordered tier: nominal=0, fair=1, serious=2, critical=3)

Register, then attach with propsFor(el, [...]); they write to the bound element. In auto mode you don’t register at all — a data-props-for attribute loads the plugin on demand:

<script type="module" src="https://unpkg.com/prop-for-that/dist/auto.js"></script>
<input data-props-for="field"> <!-- the field plugin loads itself the first time it's seen -->

Because their values land on the bound element, only that element’s descendants can read them — and @container style() only ever matches an ancestor. Add data-props-to (or { to }) to write them on an ancestor instead, so siblings — and the element itself — can react:

<figure>
<img data-props-for="img" data-props-to="figure" src="art.jpg">
<figcaption></figcaption> <!-- can read and style-query --live-loaded -->
</figure>
ImportKeyProperties
pointerLocalpointer-local--live-local-pointer-x-ratio, --live-local-pointer-y-ratio (0–1 within the element’s box), --live-local-pointer-inside (1 / 0)
mediamedia--live-current-time, --live-duration, --live-progress (0–1), --live-paused (1 / 0), --live-volume (0–1), for <video> / <audio>
fieldfield--live-length (value length), --live-empty (1 / 0), --live-valid (1 / 0), for form fields. Plus the per-reason validity flags :invalid can’t distinguish (each 1 / 0): --live-value-missing, --live-type-mismatch, --live-pattern-mismatch, --live-too-long, --live-too-short, --live-range-underflow, --live-range-overflow, --live-step-mismatch, --live-bad-input, --live-custom-error. And, when the field has a maxlength: --live-remaining (chars left), --live-fill-pct (01) for a pure-CSS counter (neither written without a maxlength — keep a var() fallback) — see the Field demo
selectselecta <select>’s state as numbers CSS can’t reach: --live-index (selectedIndex, -1 if none), --live-option-count, --live-index-pct (01), --live-value-num (Number(value), only while the value is numeric — removed again otherwise), --live-selected-count and --live-selected-pct (01) — the multi-select tally :checked can’t count. Drive a sliding segmented indicator off --live-index, or layout off --live-value-num. Bind the <select> or a container — see the Select demo
colorInputcolor-input--live-color — the colour chosen in an <input type="color"> (CSS can’t read it), as one sRGB hex string; pull channels out with relative colour syntax / color-mix(). Typed mode registers it <color> so it interpolates between picks. Bind the input or a container — see the Color input demo
fieldStatefield-statethe interaction history form libraries track but CSS can’t (all 1 / 0): --live-dirty / --live-pristine (edited at all — latches), --live-touched / --live-untouched (blurred at least once — latches), --live-changed (current value differs from mount — un-latches), --live-submitted (owning <form> submitted). Bind a single field, or a <form> for the aggregate over all its fields. Pairs with field (validity) and :focus (focus) — see the Field state demo
formStateform-stateform-level validity & completion CSS can’t count: --live-field-count, --live-valid-count, --live-invalid-count, --live-all-valid (1 / 0 — the submit gate), --live-completion (01, valid required ÷ required). Bind a <form> / wrapper — see the Form state demo
imgimg--live-natural-w, --live-natural-h (intrinsic pixel size), --live-loaded (1 / 0), --live-broken (1 / 0), for <img>
imgColorimg-colora palette from an <img> (CSS can’t read pixels), each swatch a single sRGB hex colour (no channel props — extract with relative colour syntax / color-mix()): --live-img (dominant), --live-img-accent (most vibrant), --live-img-dark (darkest non-black), --live-img-light (lightest non-white), --live-img-avg (mean), plus --live-img-temp (−1 cool … +1 warm). All from one 16×16 createImageBitmap + OffscreenCanvas pass; cross-origin images need crossorigin + CORS or it no-ops — see the Image color demo
videoColorvideo-colorlive colours of a playing <video>, each a single sRGB hex colour: --live-video (dominant) + --live-video-accent (the most vibrant; reuses the dominant for a grayscale frame). Both read 16×16 on requestVideoFrameCallback (so it stops sampling when the video is paused, offscreen, or backgrounded), throttled to ~4 Hz; cross-origin video needs crossorigin + CORS or it no-ops — see the Video color demo
truncatedtruncatedwhether the element’s own text is being clipped right now — the “is the ellipsis showing?” question CSS can ask a scroll container but never overflowing text (all 1 / 0): --live-truncated (clipped on either axis), --live-truncated-x (inline clip — the classic text-overflow: ellipsis line, scrollWidth > clientWidth), --live-truncated-y (block clip — a -webkit-line-clamp / fixed-height overflow: hidden box, scrollHeight > clientHeight). Reveal a “more” affordance, tooltip trigger, or expand control only while text is actually cut off — @container style(--live-truncated: 1) { … } — and have it vanish the moment a wider box fits. Recomputed whenever the box resizes (the shared ResizeObserver), so it flips both ways as the layout reflows. Bind it to the element whose overflow you care about — see the Truncated demo
randomrandomthree independent random rolls per bound element — the per-element variation CSS can’t compute for itself yet — each a float in [0, 1), written once: --const-random, --const-random-2, --const-random-3. One rule then varies every element it’s bound to: size from the first, tint from the second, animation-delay phase from the third — no :nth-child() ladder. Angles, ranges, buckets and coin flips are derivable from these in calc() (calc(var(--const-random) * 360deg), round(down, calc(var(--const-random) * 5), 1), calc(sign(var(--const-random-2) - 0.5))), so they aren’t shipped. Never re-rolled — const cadence plus gate: false — so an animation keeps the number it started with. configure({ randomSeed: 42 }) — or, under auto, <html data-props-seed="42"> — makes it deterministic: rolls derive from the seed plus the element’s position in the DOM, so the same markup renders the same scatter on every load (SSR, screenshot diffing, print). Native random() is the eventual platform answer — see the Random demo

Every plugin feature-detects the API it needs and quietly no-ops (returns an empty disposer) when it’s unavailable, so registering one is always safe.

orientation, motion, and geo are additionally permission-gated: on some platforms (notably iOS) they require a user-gesture permission grant before any values arrive.

cpuPressure is Chromium-only today and needs a secure context plus the compute-pressure Permissions Policy (allowed for same-origin by default). It seeds --live-cpu-pressure: 0 (nominal) where supported and writes nothing where it isn’t, so var(--live-cpu-pressure, 0) is a safe read everywhere.

imgColor and videoColor read pixels through a canvas, so a cross-origin image or video needs crossorigin="anonymous" plus permissive CORS headers; otherwise the canvas is tainted and the plugin writes nothing. Same-origin, data:, and blob: sources work without ceremony. Keep a var() fallback either way.

Not yet implemented, tracked for a later release: light (ambient light sensor), audio (Web-Audio analyser bins), caret (selection/caret position), and children (child count via MutationObserver).

Deliberately left out, because CSS or the platform already does them better:

  • :hover, :focus-within, :active: CSS pseudo-classes.
  • [open], :modal, :checked: HTML/CSS selectors.
  • prefers-*, media queries, container queries: CSS at-rules.
  • env() safe-area insets: already in CSS.
  • scroll position / progress: use native scroll-driven animations (animation-timeline: scroll() / view()), which run on the compositor. Scroll velocity and direction, which timelines can’t express, are the scroll-velocity plugin.