Skip to content

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

typescript
r$(target, map): ResponsiveHandle
r$`selector { prop: ${r$.fluid(14, 24)}px }`   // tagged-template form
  • target: selector string, Element, Element[], or NodeList.
  • map: Record<prop, StyleValue> where a value is a ResponsiveValue, a (width) => value function, or a plain string/number.
  • With a selector target and useMediaQueries on (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 to clamp(). 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

MemberMeaning
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(): ResolvedConfigRead 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): ObserveHandleSPA: 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(): ScopeGroup handles: s.add(handle) returns it unchanged, s.dispose() releases everything in reverse order. One call to tear a component down.
r$.renderStatic(): stringEvery 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

typescript
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 cqirequires 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

FunctionStatic CSS?Meaning
when(pred, a, b?) / when([[pred, v], …])noArbitrary 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.

typescript
geometry('.nav', { wrapped: whenWraps, crowded: whenOverflows });
// → <nav data-wrapped>       CSS: .nav[data-wrapped] { /* burger */ }
PredicateTrue whenExtra 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 pinnedscroll
linesOf()(number) rendered text lines → data-lines="3"
whenCollides(other)the rects of the element and other overlapscroll

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: whenWrapswhenWraps()).

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); a combine() 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 and handle.measure().
  • ratio(a, b, { min?, max? }) — the design constraint promoted to enforcement: keeps width(a)/width(b) in bounds by constraining a, and frees it while the layout complies.

Typed breakpoints

typescript
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

typescript
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 — without maxColumns it 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:

typescript
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 depending

Width sources (all SSR-safe, all disposable):

FunctionBacked 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 importNamespace
applyResponsiver$()apply a style map
applyDynamicr$.dynamicskip the static split, drive everything from JS
staticCSSr$.staticemit + inject the stylesheet, returns { css, dispose }
applyUtilitiesr$.applythe utility grammar below
parseUtilitiesthe same grammar, returning a StyleMap you can extend
batchWritesr$.batchcoalesce signal updates and style writes into one flush
defineBreakpointsr$.breakpointstyped 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$.releaseViewportHubdrop 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:

typescript
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·vMinclamp(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:

html
<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:

typescript
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 runs

Under a strict Content-Security-Policy pass r$.configure({ nonce }): every injected <style> carries it.