API — @responsivejs/runtime
The authoring half: reactive value = f(width), CSS-first. Depends only on @responsivejs/core. Subpaths: /signals, /curves, /layout, /typography, /geometry (everything is also re-exported from the root).
The entry point is r$ — one callable namespace carrying the everyday authoring surface (r$.fluid, r$.tokens, r$.geometry, r$.whenWraps, r$.breakpoints, r$.sync, …), so the editor's autocomplete is the API browser. responsive is an alias of the same object (the historical name). Precisely: every r$ member exists, plus root named exports for the same functions and for the lower layers. The namespace is a superset of the authoring surface: transforms, curve sugar, layout/typography helpers and the measurement signals are all on r$. too. Only module-level internals (emitCSS, injectStyle, registerProvenance) stay named-export-only. New to the runtime? Start from the guide, not this reference.
r$() — apply styles
r$(target, map): ResponsiveHandle
r$`selector { prop: ${r$.fluid(14, 24)}px }` // tagged-template formtarget: selector string,Element,Element[], orNodeList.map:Record<prop, StyleValue>where a value is aResponsiveValue, a(width) => valuefunction, or a plain string/number.- With a selector target and
useMediaQuerieson (default), the map is split: statically expressible values become one injected<style data-responsivejs>; the rest is JS-driven. - In the tagged-template form a literal suffix that is just a unit (
${fluid(14, 24)}px) belongs to the value: it is folded in, so the declaration still compiles toclamp(). Genuinely mixed content (${a} solid red) is a composed string and stays JS-driven — CSS has no way to express it.
ResponsiveHandle: elements, update(map), pause(), resume(), dispose(). Ownership guarantees: every handle owns a unique stylesheet (two r$('.x', …) calls never clobber each other; the cascade goes to the later injection); the inline value present before the handle's first write to a property is restored on dispose; update(map) restores properties the new map no longer contains; container: true values acquire container-type: inline-size on the parent through a refcounted owner that never overrides a user declaration (also on the static path — the stylesheet says cqi, the handle provides the container; with r$.static() alone, declaring the container is on you).
The namespace
| Member | Meaning |
|---|---|
r$.configure({ breakpoints, defaultUnit='px', useMediaQueries=true, debug, ssrWidth=1024, nonce }) | Change the config (itself reactive — both halves of every construct re-emit). nonce is copied onto every injected <style> for strict CSP. |
r$.config(): ResolvedConfig | Read the config in force (frozen copy). |
r$.breakpoints({ mobile: 320, … } as const) | Define named breakpoints — returns the typed API. |
r$.tokens({ '--space-md': fluid(8, 16) }) | Token bridge: fluid custom properties on :root. |
r$.static(selector, map): { css, dispose } | CSS-only compilation — throws if anything needs JS. Each call owns its own stylesheet (two static maps for one selector never clobber each other) and can remove it. |
r$.dynamic(target, map) | Skip the static split, drive everything via JS. |
r$.observe(selector, map): ObserveHandle | SPA: the selector stays bound as elements come and go. The static half is injected once (CSS already covers future elements); the JS half is wired per element on mount and released on removal. refresh() re-scans on demand. |
r$.scope(): Scope | Group handles: s.add(handle) returns it unchanged, s.dispose() releases everything in reverse order. One call to tear a component down. |
r$.renderStatic(): string | Every stylesheet emitted so far — what a server inlines into <head>. |
r$.lazy(target, map) | Apply on first intersection (IntersectionObserver). |
r$.batch(fn) | One signal flush + one style flush for several calls. |
r$.memo(map) | Cache custom-function values per 1px width bucket. |
r$.debug(bool) | Log resolved values on change. |
r$.flush() | Synchronously drain pending style writes (tests). |
r$.apply(target, 'text-fluid-sm-xl p-fluid-2-8') | Utility micro-grammar ({text|p|m|gap|bg|color}-fluid-{from}-{to}). |
r$.manifest() | The live provenance manifest: every active construct with target, behavior, call site and its serialized declaration (config) — also published on window.__rjs_manifest for the validation oracle: violations trace back to their owning construct, and rjs init generates contracts from it. |
Values
fluid() — polymorphic
fluid(min, max, unit? | opts?) // numbers → linear (static clamp) or curved (JS)
fluid([8, 16, 24, 32], opts?) // per-breakpoint multi-segment
fluid('#ff0000', '#0000ff', opts?) // colors → perceptual OKLab mix (JS)
fluid('scale(0.8)', 'scale(1.2)') // strings → structural interpolation (JS)FluidOpts: curve ('linear' | 'exponential' | 'logarithmic' | EasingName | Bezier), unit, container: true (bind to nearest container; static output uses cqi — requires from/to, see below), from/to (domain override — defaults to the configured breakpoint range), domain: fromElement('.sidebar') (cross-element: the value follows that element's width — always JS-driven).
container: true must come with from/to (or a domain source): it changes what is measured, not the range it is measured over, so without them the value would interpolate across your viewport breakpoints and a 240–820px card would walk a fifth of its curve. That failure is silent and reads as "the library does nothing", so it is a construction-time error instead.
String interpolation requires structural congruence (same tokens, literals, units — bare 0 inherits the other side's unit) and throws a descriptive error otherwise. No fuzzy matching.
Conditionals
| Function | Static CSS? | Meaning |
|---|---|---|
when(pred, a, b?) / when([[pred, v], …]) | no | Arbitrary predicate; first match wins. |
whenInRange(min, max, value, otherwise?) | yes* | 2013 heritage; min+max @media. |
breakpoint.below(ref, a, b?) | yes* | ref is a name or px. Mobile-first emission; without b the value is @media (max-width)-guarded — it never leaks above the threshold. |
breakpoint.above(ref, a, b?) / .between(lo, hi, a, b?) | yes* | |
breakpoint.match({ mobile: 14, desktop: 18 }) | yes* | Largest matching breakpoint wins. |
* static when the branches compile to plain declarations — including nested values: breakpoint.above('md', fluid(14, 24)) emits clamp() inside the @media block. A branch that needs its own media blocks (a per-breakpoint array) cannot nest and stays JS-driven.
Helpers
custom(fn, opts?)— wrap(width) => value; always JS.combine([...])— space-join parts (transform lists). Static when every part is.scale(v),rotate(v),translate(x, y),translateX(v),translateY(v),skew(x, y?)— transform templates with conventional default units. They compile to static CSS whenever their arguments do:transform: translateX(clamp(…)) scale(clamp(…))is ordinary CSS.isResponsiveValue(v)— brand check.
/geometry — state from geometry
The niche CSS still can't select on. JS detects, CSS styles: predicates measure facts off the live DOM; geometry() mirrors them into data-attributes for your stylesheets.
geometry('.nav', { wrapped: whenWraps, crowded: whenOverflows });
// → <nav data-wrapped> CSS: .nav[data-wrapped] { /* burger */ }| Predicate | True when | Extra sensitivity |
|---|---|---|
whenWraps() | a child starts below the first row | |
whenOverflows(axis?) | scroll size > client size ('x' default, 'y', 'both') | |
whenTruncated() | content overflows an axis whose overflow is hidden/clip | |
whenStuck() | a position: sticky element is currently pinned | scroll |
linesOf() | (number) rendered text lines → data-lines="3" | |
whenCollides(other) | the rects of the element and other overlap | scroll |
Re-measures on element resize (shared ResizeObserver), viewport resize, and scroll for the scroll-sensitive ones. Every predicate's measure(el) is pure and callable one-shot. Never display: none what a predicate measures (zeroed rects flip the state back and it oscillates) — collapse keeping layout: visibility: hidden; height: 0; overflow: hidden. GeometryHandle: elements, measure(), pause(), resume(), dispose() (removes the attributes). SSR: inert. Bare factories are accepted (wrapped: whenWraps ≡ whenWraps()).
Cross-element
fromElement(target)— a fluid domain:fluid(14, 18, { domain: fromElement('.sidebar'), from: 200, to: 600 })makes the value follow the sidebar's width, not the viewport. Honoured by every value kind (numbers, per-breakpoint arrays, colors, structural strings,custom); acombine()whose parts follow different elements throws, since a combined value has one driving width. A source selector that matches nothing throws at construction, before any stylesheet or provenance entry exists.sync(target, 'height' | 'width')— equalize a dimension across unrelated containers (max natural size wins). Re-measures on viewport resize andhandle.measure().ratio(a, b, { min?, max? })— the design constraint promoted to enforcement: keepswidth(a)/width(b)in bounds by constraininga, and frees it while the layout complies.
Typed breakpoints
const bp = r$.breakpoints({ mobile: 320, tablet: 768, desktop: 1024 } as const); // defineBreakpoints as named export
bp.below('tablet', 'column', 'row'); // autocompletes; a typo is a COMPILE error
bp.between('mobile', 'desktop', …);
bp.match({ mobile: 14, desktop: 18 });
bp.width('tablet'); // 768
bp.matches('tablet'); // reactive { signal, dispose }
bp.names; // ['mobile', 'tablet', 'desktop'] (ascending)Also configures the global runtime, so the string-based breakpoint.* API keeps working.
Tokens — fluid custom properties
const t = r$.tokens({ '--space-md': fluid(8, 16), '--font-hero': fluid(24, 48, { curve: 'exponential' }) });One write point instead of N styled elements: linear values compile to a static clamp() stylesheet on :root (zero JS at runtime); non-linear/conditional/color values update their variable from ONE viewport effect. The page consumes var(--space-md) anywhere — themable, inspectable, SSR-friendly (t.css is the stylesheet to ship). t.dynamic lists the JS-driven names; t.toDTCG() exports Design-Tokens-Community-Group JSON (static values verbatim, responsive curves sampled under $extensions['design.responsivejs']); t.dispose() removes everything.
/curves — sugar
linear, exponential, logarithmic, easeIn, easeOut, easeInOut, cubic(min, max, bezier) — each equals fluid(min, max, { curve }).
/layout
grid.adaptive({ minColumnWidth, maxColumns?, gap? }): StyleMap— withoutmaxColumnsit is pure CSS (repeat(auto-fit, minmax(min(Wpx, 100%), 1fr))); with it, the column count is computed per width.space— geometric spacing scale (base 8 × ratio 1.5^level,space.config()to change):level(n),inset(v, h?),stack(n),inline(from, to?),fluid(from, to),rhythm(n).
/typography
typography.scale('major-third' | { ratio, base: [min, max] }): TypeScale — reuses core's SCALES. size(level) is a fluid value (base × ratio^level at each domain edge); lineHeight(level) eases 1.5 → 1.2 as levels grow (display sizes tighten); spacing(level) is half the size. Unknown names throw with the valid list.
/signals — the reactive engine
TC39-shaped, zero-dep, no DOM:
state<T>(initial, equals?): State<T> // { get(); set(v) }
computed<T>(fn, equals?): Computed<T> // { get() } — lazy, version-validated, diamond-safe
effect(fn): Disposer // runs now, re-runs on change; cleanup via return
subscribe(signal, cb): Disposer // cb on change (not on subscription)
batch(fn) // defer effects, flush synchronously at exit
untrack(fn) // read without dependingWidth sources (all SSR-safe, all disposable):
| Function | Backed by |
|---|---|
viewportWidth(): State<number> | ONE passive resize listener (lazy singleton). |
mediaQuery(q): { signal, dispose } | Refcounted matchMedia registry. |
breakpointSignal(ref) | mediaQuery('(min-width: …)') via named breakpoints. |
containerWidth(el): { signal, dispose } | ONE shared ResizeObserver, refcounted per element. |
elementSize(el): { signal, dispose } | {width, height} off the same observer and refcount. |
scrollTick(): Computed<number> | ONE capture-phase scroll listener (nested containers too). |
releaseViewportHub() | Drop every listener/observer and all registries (embedded hosts, SPA teardown). Signals re-arm lazily. |
Every signal the hub hands out is read-only (Computed): the entries are shared, so a consumer writing to one would desynchronize every other consumer of the same element.
Named imports ↔ the namespace
r$.x and the named import are the same function — the namespace is a convenience, not a wrapper. Import names differ in a few places where the bare word would be too generic:
| Named import | Namespace | |
|---|---|---|
applyResponsive | r$() | apply a style map |
applyDynamic | r$.dynamic | skip the static split, drive everything from JS |
staticCSS | r$.static | emit + inject the stylesheet, returns { css, dispose } |
applyUtilities | r$.apply | the utility grammar below |
parseUtilities | — | the same grammar, returning a StyleMap you can extend |
batchWrites | r$.batch | coalesce signal updates and style writes into one flush |
defineBreakpoints | r$.breakpoints | typed breakpoints |
bpWidth(name) | — | the px of a named breakpoint; throws with the valid names |
emittedStyles() | — | every stylesheet key r$ has injected (tests, SSR audits) |
releaseViewportHub() | r$.releaseViewportHub | drop the shared listeners (tests) |
Everything else is spelled the same in both forms.
The utility grammar — r$.apply
r$.apply(target, spec) parses a Tailwind-shaped string into a style map, for when a fluid value is easier to write inline than to declare:
r$.apply('.card', 'text-fluid-sm-2xl p-fluid-12-32 bg-fluid-slate50-slate200');Grammar: {alias}-fluid-{from}-{to}, where alias is text · p · m · gap · bg · color. Sizes accept the named scale (xs sm base lg xl 2xl 3xl) or raw numbers; colours accept hex or the named set. Anything it cannot parse throws naming the grammar — no silent partial application. parseUtilities(spec) returns the map instead of applying it, so you can merge it with hand-written declarations.
Emission
emitCSS(selector, map): { css, dynamicRest } and injectStyle(css, key) / removeStyle(key) are exported for build-time use. Linear fluid compiles with the Utopia formula: slope = (max−min)/(vMax−vMin), intercept = min − slope·vMin → clamp(lo, calc(intercept + slope·100vw), hi) (bounds reordered for descending ranges, cqi for containers).
No build step
@responsivejs/runtime/global is the whole runtime as one IIFE (~15.5 kB gzip). Drop it in with a <script> and window.r$ is the same callable namespace — nothing else changes, the CSS-first split included:
<script src="https://unpkg.com/@responsivejs/runtime/dist/global.js"></script>
<script>
r$.tokens({ '--space-m': r$.fluid(16, 24) }); // → clamp() on :root, zero JS after this
r$.geometry('.site-nav', { wrapped: r$.whenWraps });
</script>For CMS pages, plain HTML, docs demos, and agents injecting the runtime into a page they do not own. responsive is published as an alias of the same object.
SSR
No window access at module level; values resolve at config.ssrWidth until hydration. The CSS-first half is fully server-renderable:
r$('.hero', { fontSize: r$.fluid(16, 32) }); // handle.css is its compiled half
r$.tokens({ '--space-m': r$.fluid(16, 24) }); // tokens().css likewise
const sheet = r$.renderStatic(); // …or every emission at once
// → inline `sheet` into <head> and the page is correct BEFORE any JS runsUnder a strict Content-Security-Policy pass r$.configure({ nonce }): every injected <style> carries it.