@zakkster/lite-media
v1.5.1
Published
Reactive media, container-query, and preference signals for @zakkster/lite-signal. Wraps window.matchMedia and the browser's native @container evaluator in Signal<boolean>. Zero-GC steady state, scoped instances via createMedia().
Maintainers
Readme
@zakkster/lite-media
Reactive media & preference signals for
@zakkster/lite-signal. Wrapswindow.matchMediain aSignal<boolean>with page-lifetime memoization. Zero-GC steady state.
npm install @zakkster/lite-media @zakkster/lite-signalNumbers
Measured on Apple M4 Pro (12 cores) · Node 26.3.1 · darwin/arm64. Run npm run bench on your box for local figures — the harness prints a machine stamp so you can reproduce them.
| Scenario | Throughput | Per-op |
| ------------------------------------------------- | ----------------- | ------------- |
| media(q) cache hit | 213 M ops/s | 4.7 ns |
| Signal read (call-style, s()) | 1.44 B ops/s | 0.7 ns |
| Preference shortcut (reducedMotion()) | 187 M ops/s | 5.4 ns |
| change event → sig.set → effect (1 sub) | 46 M ops/s | 21.8 ns |
| Fanout to 100 subscribers | 849 K ops/s | 1.18 μs total, ~12 ns/sub |
| createMedia() instance creation | 21 M ops/s | 47.8 ns |
| Cold materialize (mock MQL, cache miss) | 1.6 M ops/s | 624 ns |
| createMedia() + 5 signals fully wired | 674 K ops/s | 1.48 μs |
~3.5 KB min+gz (3,529 B, esbuild + gzip -9, lite-signal external) for the full module — Engine A, Engine B (containerMedia + containerStyle, multi-root as of v1.4.0, bfcache-aware as of v1.4.1), breakpoints, createMedia, 10 preferences, configure, stats. The dev-only container-type warning is behind a process.env.NODE_ENV !== "production" guard, so a production build drops it.
Hello
import { effect } from "@zakkster/lite-signal";
import { media, reducedMotion, hoverCapable } from "@zakkster/lite-media";
const small = media("(max-width: 37.5rem)");
const rm = reducedMotion();
const hover = hoverCapable();
effect(() => {
layout.compact = small();
if (!rm()) animate();
if (hover()) enableHoverPreviews();
});Every call returns a lite-signal Signal<boolean> that reflects matchMedia(query).matches and updates on change. The same query string always returns the same signal — memoized for the page lifetime, so N components asking for (max-width: 37.5rem) share exactly one browser subscription.
The one-line design
JS never evaluates a query. The browser evaluates; lite-media only observes the verdict and pushes a boolean into the graph.
Two consequences that fall out of that:
- Correctness is free, forever. Range syntax,
emresolved against font-size,dvh, container queries, media features that haven't shipped yet — the browser handles them. lite-media doesn't parse queries, doesn't compare pixels, doesn't own an evaluator. - Zero-GC steady state. A
changeevent runs one hoisted handler that does onesig.set(e.matches). lite-signal's defaultObject.isequality drops redundant events. No timers, no debounce, no allocations after the signal is created.
Engine B (containerMedia, v1.1) follows the same rule via CSS custom-property transitions on a zero-size sentinel — the browser fires transitionrun when a container's verdict flips, and lite-media pushes the new boolean into the signal graph. containerStyle (v1.3) is the same engine pointed at a style() condition instead of a size one: still no parser, still one observed verdict.
bfcache is the one case the browser doesn't tell you about (v1.4.1). A page restored from the back/forward cache does not re-fire change or transitionrun, so a signal could hold an answer that went stale while the page was frozen (the user flipped dark mode, rotated the device, resized the window). Each instance lazily attaches one pageshow listener; on a persisted restore (event.persisted === true) — and only then — it re-reads every watched mql.matches and every live sentinel's verdict and re-pushes each through the same sig.set. Object.is drops the ones that didn't move, so an unchanged restore retains 0 B and runs no effects, while a verdict that changed while frozen propagates exactly once. Every read is fail-closed; in Node / SSR (no globalThis.addEventListener) nothing is attached. No application code to call — it just stops being stale.
API
media(query: string): MediaSignal
Return the memoized reactive boolean signal for a CSS media query. Same query string => same signal instance. Reads (s(), s.peek(), s.subscribe()) behave as lite-signal defines. The .d.ts narrows the return type to hide .set / .update.
const wide = media("(min-width: 60rem)");
effect(() => document.body.classList.toggle("wide", wide()));Throws Error if no matchMedia is available and no ssrDefault was configured. Throws TypeError if a configured factory returns a non-MQL-shaped object.
containerMedia(el: Element, query: string): MediaSignal — new in v1.1
Return the memoized reactive boolean signal for a container query, scoped to an element. The element must have container-type set (inline-size, size, or normal). Cached per (element, query) pair via a WeakMap keyed on the element — detached elements become GC-eligible along with their signals.
const card = document.querySelector(".card");
card.style.containerType = "inline-size";
const wide = containerMedia(card, "(min-width: 30rem)");
effect(() => card.classList.toggle("card-wide", wide()));Under the hood: lite-media injects one @container ${query} { .__lm-s[data-q="N"] { --lm-v: on; transition: --lm-v 0.001ms allow-discrete; } } rule per unique query, appends a zero-size sentinel <div> to the container, and listens for transitionrun on the registered <custom-ident> custom property. When the container matches, --lm-v flips off → on (or reverse), the browser fires transitionrun, and the handler reads the new value and calls sig.set(...).
Preconditions:
- Query strings, like
media(), must come from a bounded static set — they're inserted into a live stylesheet (see "Memoization contract"). - The container must have
container-typeset. lite-media cannot set it for you (it would over-specify layout; users need control here). In development builds (process.env.NODE_ENV !== "production"), if neither the element nor any ancestor is a query container, lite-media logs a one-timeconsole.warnnaming the element and the fix. It warns, never mutates, and the warning is dropped from production builds.
Off-DOM (SSR / Node without a container engine), containerMedia() returns a stable false signal and never throws — the conservative, fail-closed verdict. Unlike media(), ssrDefault does not apply to this path.
Multi-root (v1.4.0). The engine resolves el.getRootNode() and keeps one constructed @container sheet per root — the main document, a shadow root, or a cross-realm iframe document — adopted into that root and built in that root's own realm. An element inside a shadow root or an embedded iframe (e.g. a Twitch panel) now materializes against its own root's sheet instead of sitting silently stuck at false. --lm-v is still registered exactly once, the per-root sheet map is a WeakMap (detached roots are never pinned), and a root that can't be styled fails closed to a stuck-false signal — never throws, never adopts a wrong-realm sheet. The root is resolved once, at the element's first watch(), and memoized per element; relocating an element to a different root after its signal exists is a non-goal — dispose and re-create the signal if an element moves roots.
Throws TypeError if el is null / primitive or query is not a string.
containerStyle(el: Element, prop: string, value: string): MediaSignal — new in v1.3
Return the memoized reactive boolean signal for a container style query: is prop computed to value on the element's nearest ancestor container? This is Engine B's style() class — the same sentinel + transitionrun machinery as containerMedia, pointed at a style condition.
const card = document.querySelector(".card");
// A parent sets --theme somewhere up the tree; no container-type needed.
const dark = containerStyle(card, "--theme", "dark");
effect(() => card.classList.toggle("card-dark", dark()));containerStyle(el, prop, value) constructs the canonical condition style(<prop>: <value>) and delegates to containerMedia, so it shares that path's engine, memoization and disposer. A raw containerMedia(el, "style(--theme: dark)") with identical spacing returns the same cached signal.
- No
container-typerequired. Unlike a size query, astyle()query resolves against any ancestor element regardless of itscontainer-type, so the missing-container footgun does not apply — the dev-only warning is suppressed for it (LM-04). - No namespace pollution. lite-media registers exactly one CSS custom property (
--lm-v). The queried property is yours; lite-media never registers it. If you want guaranteed inheritance or typing on it, register it withCSS.registerPropertyyourself. - Same SSR contract as
containerMedia: off-DOM it returns a stablefalsesignal and never throws.
Throws TypeError if el is null / primitive, prop is not a non-empty string, or value is not a string.
createMedia(options?): ScopedMedia — new in v1.1
Create a scoped instance with its own memoization cache. This is the fundamental factory; the module-level media, configure, etc. delegate to a lazily-created default instance backed by this same factory.
import { createMedia } from "@zakkster/lite-media";
// Per-request SSR: fresh instance per request.
export function handleRequest(req) {
const m = createMedia({
ssrDefault: isMobileUA(req.headers["user-agent"]),
});
const compact = m.media("(max-width: 37.5rem)");
return renderPage({ compact: compact() });
}Options are creation-only. Scoped instances never lock — there's no configure() step, no lock error to worry about.
interface CreateMediaOptions {
matchMedia?: (q: string) => MockMediaQueryList;
ssrDefault?: boolean;
containerEngine?: ContainerEngine | null;
}Use createMedia() for:
- Per-request SSR (each request creates a fresh instance with a request-specific default).
- Tests that need isolation without touching module state.
- Multiple embed points on one page with different mocks / defaults / engines.
configure(options): void
Configures the default instance. Must be called before the first successful media() / containerMedia() / preference call. Same lock semantics as v1.0. Scoped createMedia() instances take options at creation and have no configure() method.
configure({
matchMedia: (q) => mockMediaQueryList(q),
ssrDefault: false,
containerEngine: myMockEngine, // v1.1
});- Non-function
matchMediathrowsTypeErrorat the call site. - Non-boolean
ssrDefault(includingundefined) is a no-op. - Malformed
containerEngine(missing.watch(el, query, onChange)) throwsTypeError. null/undefined/ non-object input are all safe no-ops.- Multiple pre-lock calls compose (later values override earlier).
- Reconfiguration after successful materialization throws.
- Failed materialization does not engage the lock.
Preference shortcuts
Each is () => MediaSignal — hoist once per component, then read call-style. All 10 available as methods on scoped createMedia() instances too. standaloneDisplay() and highDynamicRange() are new in v1.2.
| Shortcut | Query |
| ----------------------------- | ----------------------------------------- |
| reducedMotion() | (prefers-reduced-motion: reduce) |
| darkScheme() | (prefers-color-scheme: dark) |
| hoverCapable() | (hover: hover) |
| coarsePointer() | (pointer: coarse) |
| forcedColors() | (forced-colors: active) |
| moreContrast() | (prefers-contrast: more) |
| reducedData() | (prefers-reduced-data: reduce) |
| reducedTransparency() | (prefers-reduced-transparency: reduce) |
| standaloneDisplay() | (display-mode: standalone) |
| highDynamicRange() | (dynamic-range: high) |
breakpoints(map): BandSignal — new in v1.2
Compile a named breakpoint map into a single reactive string signal — the name of the active band:
import { breakpoints } from "@zakkster/lite-media";
const band = breakpoints({ sm: 0, md: 768, lg: 1024 }); // min-width in px
effect(() => {
document.body.dataset.bp = band(); // "sm" | "md" | "lg"
});The active band is the name of the highest threshold whose (min-width: Npx) currently matches; the smallest entry is the mobile-first floor, returned whenever nothing larger matches. Key order does not matter — the map is sorted by threshold.
- One signal, not N. The map compiles to one
computed<string>. The band names are the map's own keys, returned by reference, so an unchanged band is===-stable — downstream effects run exactly once per real band change, never per resize frame. - No width math in JS. Each boundary is a constructed
(min-width: Npx)query observed throughmedia()(boundary signals are shared withmedia()), so the sentinel thesis holds and a band flip is a zero-allocation token swap. - Memoized per map (any key order → the same signal), and counted in
stats().bands. - Fails loud: a non-object, empty, or non-finite/negative-valued map throws
TypeError. Off-DOM it followsssrDefault—falseyields the floor band,truethe top band; with neithermatchMedianorssrDefaultit throws likemedia().
With a literal map, TypeScript narrows the value to the union of the keys (BandSignal<"sm" | "md" | "lg">).
stats(): { watched, bands, configured, locked }
Cheap live snapshot for demos, perf overlays, and test assertions.
Read-only by convention
MediaSignal in the .d.ts is a structural narrowing of lite-signal's Signal<boolean> — it hides .set and .update from TypeScript. At runtime, the returned object still has those methods. A JS consumer or a TS as cast can call .set(), and doing so will permanently desync the shared signal from the browser until the next real change event lands.
If you find yourself wanting to write to a media signal, wrap it in a computed() you own.
Memoization contract
Both media() and containerMedia() memoize by exact string match, for the lifetime of the instance (no eviction). The design assumes query strings come from a bounded static set — interned literals, module-level constants, or a finite table of known breakpoints.
Additionally for containerMedia: query strings are inserted into a live <style> sheet. Never pass untrusted input — treat container queries like CSS you're authoring, not like user data. If you need bounded runtime dispatch, bucket to a fixed table of well-known breakpoints first.
// WRONG — leaks a signal + a listener per unique width, plus a live CSS rule.
for (let w = 300; w < 1300; w++) media(`(max-width: ${w}px)`);SSR & per-request state
In v1.1, per-request SSR is a first-class pattern via createMedia():
// per-request handler
export function handle(req) {
const m = createMedia({
ssrDefault: guessMobile(req.headers["user-agent"]),
});
// ...use m.media(), m.reducedMotion(), etc.
// Instance goes out of scope after the response; GC handles cleanup.
}The default instance (module-level media, configure, reducedMotion, ...) still uses a process-global cache with process-wide ssrDefault — that's the right shape for browser pages and the wrong shape for request-scoped state. Use createMedia() when you need isolation.
Testing
__resetForTests() clears the default instance's cache, config, and lock in one call. Node's --test runs each test file in its own subprocess by default, so cross-file isolation is free.
import { test, beforeEach } from "node:test";
import { configure, media, __resetForTests } from "@zakkster/lite-media";
beforeEach(() => __resetForTests());
test("your test", () => {
configure({
matchMedia: (q) => ({ matches: false, addEventListener() {} }),
});
// ...
});For container-query tests, pass a mock engine:
const mockEngine = {
watch(el, query, onChange) {
// Record for later flip() calls
registry.set(el, { query, onChange });
return { initial: false, dispose: () => registry.delete(el) };
},
};
configure({ containerEngine: mockEngine });Or use createMedia({ containerEngine: mockEngine }) for tests that shouldn't touch module state.
209 tests across 21 files (npm test), plus test/torture.mjs (npm run test:torture). A mechanical ASCII gate (test/21-ascii.test.mjs) fails on any non-ASCII source codepoint except the two allowed exceptions (U+00D7, U+00B5), with a failing control proving it bites.
Zero-GC accounting
| Op | Allocations |
| ---------------------------------------- | ------------------------------------------------- |
| media(query) — cache hit | 0 hot-path (Map lookup only) |
| media(query) — cache miss, mock | 1 signal + 1 handler closure + 1 MQL (from mock) |
| media(query) — cache miss, native, 1st | 1 signal + 1 handler + 1 MQL + 1 bound-native fn |
| media(query) — cache miss, native, ≥2 | 1 signal + 1 handler + 1 MQL |
| containerMedia(el, q) — cache hit | 0 hot-path (WeakMap + Map lookup) |
| containerMedia(el, q) — cache miss, 1st per query | 1 sentinel <div> + 1 handler + 1 signal + 1 CSS rule + 1 registered property (first time only) |
| containerMedia(el, q) — cache miss, ≥2 per query | 1 sentinel + 1 handler + 1 signal |
| containerStyle(el, p, v) — cache hit | 0 hot-path (delegates to containerMedia) |
| containerStyle(el, p, v) — cache miss | same as containerMedia + 1 constructed condition string |
| Style verdict flip (transitionrun) | 0 (identical to containerMedia; committed 0 B/flip) |
| breakpoints(map) — cache hit | 0 hot-path (Map lookup) |
| breakpoints(map) — cache miss | 1 computed + N boundary signals (via media(), once per threshold) |
| Band read (band()) / band change | 0 (cache hit; a change is a ===-stable token swap) |
| Signal read (s()) | 0 |
| change event → sig.set | 0 |
| transitionrun event → sig.set | 0 hot-path (getComputedStyle read is cached) |
| Redundant event (same value) | 0 (Object.is dedup in lite-signal) |
| Preference shortcut call | 0 additional (delegates to media()) |
| bfcache restore — unchanged verdict | 0 retained (each sig.set dedups via Object.is)|
| bfcache restore — changed verdict | 0 additional (one sig.set per moved verdict) |
| createMedia(opts) call | 1 instance object + 2 Maps + 1 WeakMap |
Cost scales with verdict flips, not with browser events: an event that doesn't move the answer costs one Object.is comparison and stops. A bfcache restore (a persisted pageshow) re-reads every watched verdict once and pushes only the ones that actually moved while the page was frozen — an unchanged restore retains 0 B.
Reduced-motion rAF gate — new in v1.5.0
If you are already in the lite-signal graph (already depending on @zakkster/lite-signal), gate an animation loop on reducedMotion() with a single effect. When the user prefers reduced motion the loop parks (the in-flight frame is cancelled, no new frame is scheduled); when they flip it back off the loop resumes; on dispose the effect's onCleanup cancels the last frame. No per-frame preference check, no ad-hoc matchMedia(...).addEventListener bookkeeping.
import { effect, onCleanup } from "@zakkster/lite-signal";
import { reducedMotion } from "@zakkster/lite-media";
const raf = requestAnimationFrame;
const cancel = cancelAnimationFrame;
function startAnimation(draw) {
const prefersReduced = reducedMotion(); // memoized MediaSignal; read () inside the effect
let id = 0;
function loop(t) { draw(t); id = raf(loop); }
// One effect. reducedMotion() is READ inside so the dependency is tracked.
return effect(() => {
if (prefersReduced()) { cancel(id); return; } // park: cancel, schedule nothing
id = raf(loop);
onCleanup(() => cancel(id)); // fires on re-run AND on dispose
});
}
const stop = startAnimation(drawFrame);
// ...later: stop(); // tears the loop down, no orphaned frame callbackWhy an effect and not a per-frame if (reducedMotion()):
- Fail-closed for free. Off-DOM / SSR / any environment without
matchMedia,reducedMotion()fails closed (seemedia()); the loop never starts. Do not monkeypatchglobalThis.requestAnimationFrameto test this — injectraf/cancelso the no-rAF path stays visible. - Zero allocation per flip. Toggling the preference re-runs the effect (one
cancel/raf+ one transientonCleanupclosure) and retains 0 B — committed bytest/torture.mjs(rafGate.bytesPerFlip = 0,rafGate.majors = 0over a 20 000-flip storm). - Zero frames while parked. Once parked, no scheduler ticks run the loop (
rafGate.framesWhileParked = 0); a control that omits thecancelruns ≥ 1000 frames, proving the cancel is load-bearing. - No leak on dispose. 4096 attach/park/dispose cycles return the live-set to 0 (
rafGate.liveSetAfter = 0).
The recipe is proven in test/20-raf-gate.test.mjs (conformance) and the rAF-gate torture tiers.
Ecosystem — vendor vs depend
lite-media does not become a runtime dependency of packages whose identity is zero dependencies.
lite-ambient-fxandlite-scratch-fxkeep vendoring their ownprefers-reduced-motioncheck. Each already readsmatchMedia("(prefers-reduced-motion: reduce)")behind its own guard; the core stays zero-dependency, and any heavier capability lives behind an optional peer / separate export path rather than a hard dependency on lite-media. Turning lite-media into their runtime dep would trade their zero-dep guarantee for a convenience they do not need.- The rAF-gate recipe above is for consumers already in the signal graph. If a package already depends on
@zakkster/lite-signal,reducedMotion()+effect({ scheduler })replaces its ad-hoc preference bookkeeping with a tracked, zero-GC, fail-closed gate — at no new dependency cost, because lite-signal is already present.
In short: depend on lite-media when you are already reactive; vendor a one-line matchMedia check when your identity is zero-dep.
Roadmap
- v1.0.0 — Engine A:
media(), 8 preferences,configure(),stats(). - v1.1.0 — Engine B (
containerMedia),createMedia()factory. - v1.1.2 — Dev-only
container-type: normalwarning; SSR container contract + registry-bounds invariant pinned;test/torture.mjsproof gate (0 B/flip, 0 ArrayBuffer growth committed as numbers). - v1.2.0 —
breakpoints({ sm, md, lg })interned-token band computed (0 B/band-change committed);standaloneDisplay()+highDynamicRange()complete the ten preference signals. - v1.3.0 —
containerStyle(el, prop, value)— Engine B's@container style()class through the same sentinel primitive (0 B/flip committed); thecontainer-typefootgun warning now skipsstyle()queries (LM-04). - v1.4.0 — Multi-root Engine B: shadow DOM + cross-realm iframe (one constructed sheet per root, adopted into that root, built in that root's realm); one
--lm-vacross roots, fail-closed per root, interleaved-dispose torture committed. - v1.4.1 — bfcache /
pageshowlifecycle audit: a persistedpageshowre-pins every Engine Amqlverdict and every Engine B sentinel verdict through the samesig.seta real event uses. 0 B on an unchanged restore, fail-closed per entry; bfcache resync + live-set retention torture committed. - v1.5.0 — Ecosystem wiring (Option A): a tested, copy-pasteable reduced-motion rAF-gate recipe for consumers already in the signal graph (
reducedMotion()+effect({ scheduler })), its conformance + torture gates (0 B/flip, 0 major GC, 0 frames while parked, live-set 0), and the vendor-vs-depend decision record. Media.js runtime is unchanged from 1.4.1 (only the version-header comment changed; min+gz bundle byte-identical). - v1.5.1 — Hardening only: a mechanical source-ASCII gate (
test/21-ascii.test.mjs) with a failing control. No runtime change; min+gz bundle byte-identical to 1.5.0 (only the version-header comment changed). This release.
Watchlist: CSSWG #6205 — a native Element.matchContainer() would collapse Engine B to a feature-detected bridge without changing the signal-graph surface.
Browser & runtime support
Pure ES2020. Any environment that runs @zakkster/lite-signal and can either provide globalThis.matchMedia or accept a mock through configure().
containerMedia additionally requires:
- Container queries (
@containerrules) — Chrome 105+, Firefox 110+, Safari 16+. CSSStyleSheet.adoptedStyleSheets— Chrome 96+, Firefox 101+, Safari 16.4+.transition-behavior: allow-discrete— Chrome 117+, Firefox 129+, Safari 17.4+.
All of the above are in Baseline 2024. On older browsers, containerMedia still returns a signal — it just stays at its initial value. Graceful degradation, not runtime error.
| Target | Supported |
| -------------------------------------- | -------------------------------------------- |
| Chrome / Edge 117+ | full (media + container) |
| Firefox 129+ | full (media + container) |
| Safari 17.4+ | full (media + container) |
| Older evergreen (Chrome 105–116, etc.) | media() full; containerMedia() inert |
| Node.js 18+ | media() via SSR default or mock; containerMedia() inert |
| Bun | same as Node |
| Deno | media() via SSR default or mock; containerMedia() inert (no matchMedia) |
| Twitch Extensions | full |
ESM only. sideEffects: false — tree-shakes cleanly.
License
MIT © Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal·lite-watch-ex·lite-store·lite-throttle·lite-debounce·lite-raf·lite-color-engine
