# responsive-media — AI Reference
Reactive boolean state from CSS media queries and element size — for
Vanilla JS, Vue 3, and React 19+. Not a responsive-image/srcset package
(no `
`/`` handling anywhere — that's a different domain,
see `vue-image-kit`'s own AI reference for that) and not a CSS
`@container` polyfill in the visual sense: this is a boolean-state
engine driven by `matchMedia` (viewport) or `ResizeObserver` (element),
with AND/OR condition combinators, ordered-breakpoint helpers, a rich
subscription API, CSS-var/DOM-event sync, and SSR-safety. Framework
adapters are thin wrappers around one shared, framework-agnostic core.
Version 2.0.0.
This document is hand-written for AI agents and other tools that generate
code against this package: every signature, default, and behavior note
below is verified directly against the TypeScript source (not summarized
from prose docs). For human-readable narrative docs, see the interactive
site instead:
- Full docs (EN): https://npm.vuecraft.ru/en/packages/responsive-media/guide/overview
- Full docs (RU): https://npm.vuecraft.ru/packages/responsive-media/guide/overview
- GitHub: https://github.com/macrulezru/responsive-media
- npm: https://www.npmjs.com/package/responsive-media
Links below starting with "/" are relative to https://npm.vuecraft.ru.
---
## 1. Package map
| Import path | Contains |
|---|---|
| `responsive-media` | Framework-agnostic core: `ReactiveResponsiveState`, the `responsiveState` singleton + standalone helpers, `ContainerState`, `subscribeMediaQuery`, presets, all shared types. **Zero framework code** — importable with neither Vue nor React installed. |
| `responsive-media/vue` | Vue 3 adapter: `useResponsive`, `useBreakpoints`, `useMediaQuery`, `useContainerState`, `ResponsivePlugin`. |
| `responsive-media/react` | React 19+ adapter: the same four hooks (no plugin — React has no DI equivalent used here). |
| `responsive-media/presets` | `TailwindPreset`, `BootstrapPreset`, `AccessibilityPreset` (+ their `Order` arrays) — also already re-exported from the root. |
| `responsive-media/container` | `ContainerState`, `createContainerState` — also already re-exported from the root; a dedicated entry exists for callers who want only this. |
**As of 2.0.0**: the Vue adapter moved to its own `/vue` entry (this is a
**breaking change** from 1.x, where `useResponsive`/`useBreakpoints`/etc.
were re-exported from the package root). Before 2.0.0, importing the
root — the exact path used in every 1.x example, including for plain
Vanilla JS and React consumers — unconditionally pulled in a static
`import ... from '@vue/runtime-core'`, with no `dependencies` entry (only
an *optional* `vue` peer) to guarantee it resolved; a vanilla-JS or
React-only consumer without `vue` installed would hit an unresolvable
import. `/vue` now mirrors the already-separate `/react` entry, and the
root is genuinely framework-agnostic. If you see 1.x-era example code
importing Vue composables from the bare `'responsive-media'` specifier,
it needs `'responsive-media/vue'` instead.
`peerDependencies`: `vue: ^3.5.27` and `react: ^19.0.0`, both
`optional: true`. No Nuxt module, no Vite plugin, no CLI.
---
## 2. Architecture — one shared engine, three source types
Every reactive instance (`ReactiveResponsiveState`, `ContainerState`)
extends the same abstract `BaseResponsiveState` (not exported as a
constructible default — it's the shared engine, documented in full in
section 3 since its methods are what you actually call). Three ways
state gets driven:
1. **Viewport breakpoints** — `ReactiveResponsiveState`: one
`window.matchMedia(query)` + `'change'` listener per configured key.
2. **Container queries, evaluated in JS** — `ContainerState`: one
`ResizeObserver` on a specific element; conditions are evaluated
against `contentRect`/`getBoundingClientRect()` with plain arithmetic
— **not** real CSS `@container` at-rules under the hood (see gotcha 4
for exactly which condition types this can and can't evaluate).
3. **A single raw media query string** — `subscribeMediaQuery()`, a
minimal one-off `matchMedia` subscription used internally by both
frameworks' `useMediaQuery` hook, also exported standalone.
Both `ReactiveResponsiveState` and `ContainerState` are thin wrappers
around the same state-management machinery — Vue's and React's hooks are
themselves thin wrappers around one shared, framework-agnostic singleton
(`responsiveState`, from `create-responsive.ts`), not independent state
machines per framework.
**SSR**: `isSSR()`/`hasMatchMedia()`/`hasResizeObserver()` (all
`typeof window === 'undefined'`-based) gate every source setup — on the
server, every configured key is simply set to `false` and no
listener/observer is created. There's no automatic re-evaluation once
hydrated client-side beyond whatever the app's own mount lifecycle
already triggers (a fresh `matchMedia`/`ResizeObserver` read happens
naturally once a client instance is constructed) — `hydrate()` exists
specifically to seed a server-computed snapshot into a client instance to
avoid a layout flash (section 3.7).
---
## 3. `BaseResponsiveState` (abstract) — the shared engine's full API
Every method below is available on both `ReactiveResponsiveState` and
`ContainerState` instances (including the `responsiveState` singleton
and anything from `createResponsiveState()`/`createContainerState()`).
```ts
type ResponsiveState = Record;
type ResponsiveListener = (state: ResponsiveState) => void;
interface SetConfigOptions {
debounce?: number; // ms, default 0 (disabled) — affects subscribe()/onNextChange()/onBreakpointChange() ONLY; on()/onEnter()/onLeave()/once()/waitFor() are NEVER debounced
order?: string[]; // explicit order for isAbove/isBelow/between/current; default: config key insertion order
}
interface SyncCSSVarsOptions { element?: HTMLElement /* default document.documentElement */; prefix?: string /* default '--responsive-' */ }
interface EmitDOMEventsOptions { prefix?: string /* default 'responsive:' */ }
interface WritableSignal { value: T }
```
### 3.1 Reading state
```ts
public proxy: ResponsiveState // the actual reactive state — writes here trigger notifications, see gotcha 1
getState(): T // a STABLE snapshot reference — same object identity between changes, safe for React's useSyncExternalStore
getMediaQueries(): Record // generated CSS query strings per key
getOrder(): string[] // configured order, or [] if none set
get current(): string | null // first active key in effective order; reads LIVE state, not debounced or snapshot-based
isAbove(key: string): boolean
isBelow(key: string): boolean
between(from: string, to: string): boolean
```
`effectiveOrder()` = `order.length ? order : Object.keys(state)` —
falls back to config key insertion order, and this reliably holds even
across a `setConfig()` reconfiguration (state keys are always
deleted-then-repopulated in config declaration order).
### 3.2 `setConfig(config, options?)`
Re-applies a new config: clears old state keys, tears down old
matchMedia/ResizeObserver sources, sets up new ones, and **always fires
one immediate flush** (`flushNotify()`) at the end regardless of
`debounce` — so `subscribe()` listeners always see the fresh state
synchronously-ish after `setConfig()`, even with debouncing configured.
### 3.3 Subscription API
```ts
subscribe(listener: ResponsiveListener): () => void
// Fires IMMEDIATELY with current state on subscribe. Debounced if configured.
on(key: string, cb: (matches: boolean) => void): () => void
// Fires immediately with the key's current value. NEVER debounced.
onEnter(key: string, cb: () => void): () => void // false→true only, skips the initial fire. Never debounced.
onLeave(key: string, cb: () => void): () => void // true→false only, skips the initial fire. Never debounced.
once(key: string, cb: (matches: boolean) => void): () => void
// Fires on the NEXT change only (not the current value), then auto-unsubscribes. Never debounced.
onNextChange(cb: (state: ResponsiveState) => void): () => void // next GLOBAL change only, then auto-unsubscribes. Debounced.
onBreakpointChange(cb: (from: string | null, to: string | null) => void): () => void // fires when `current` changes. Debounced.
waitFor(key: string, expectedValue = true): Promise // resolves immediately if already met. Never debounced.
```
### 3.4 Utilities
```ts
syncCSSVars(options?: SyncCSSVarsOptions): () => void
// Sets `${prefix}${key}` to '1'/'0' on `element`, removes stale props on a config change that drops keys. No-op on SSR.
hydrate(initialState: Record): void
// Only updates keys ALREADY present in the current state — a key not in the live config is silently dropped, no error.
toSignal>(key: string, factory: (initial: boolean) => T): T
// Binds a key to any writable-signal shape ({ value: T }) — works with @preact/signals-core,
// Angular signal(), a plain Vue ref(), etc. Kept in sync via on() internally.
emitDOMEvents(target: EventTarget = document, options?: EmitDOMEventsOptions): () => void
// Dispatches `${prefix}change` (detail = full state) on every change, plus `${prefix}${key}:enter`/`:leave`
// per transitioning key. No-op on SSR. The FIRST subscribe callback (the immediate current-state fire)
// is used only to seed `prev` — it does NOT dispatch any DOM events itself.
destroy(): void
// Tears down matchMedia/ResizeObserver listeners, clears all subscribers, cancels any pending debounce timer.
```
---
## 4. Core types (`responsive.enum.ts`)
```ts
type Breakpoint = 'mobile' | 'tablet' | 'desktop'; // the built-in ResponsiveConfig's own key set
interface MediaQueryCondition {
type:
| 'width' | 'min-width' | 'max-width'
| 'height' | 'min-height' | 'max-height'
| 'aspect-ratio' | 'min-aspect-ratio' | 'max-aspect-ratio'
| 'orientation'
| 'resolution' | 'min-resolution' | 'max-resolution'
| 'color' | 'min-color' | 'max-color'
| 'color-index' | 'min-color-index' | 'max-color-index'
| 'monochrome' | 'min-monochrome' | 'max-monochrome'
| 'scan' | 'grid'
| 'prefers-color-scheme' | 'prefers-reduced-motion' | 'prefers-contrast'
| 'hover' | 'any-hover' | 'pointer' | 'any-pointer'
| 'forced-colors' | 'display-mode' | 'update'
| 'raw'; // inserted verbatim, UNPARENTHESIZED — for media types like 'print'/'screen', or any raw token
value: number | string;
}
type MediaQueryConfig = MediaQueryCondition[] | MediaQueryCondition[][];
// Flat array → all conditions AND'd. Array of arrays → inner arrays AND'd, outer arrays OR'd (comma-joined).
// The shape is detected at runtime via `Array.isArray(conditions[0])`.
type ConfigToState> = { [K in keyof T]: boolean };
const ResponsiveConfig: Record = {
mobile: [{ type: 'max-width', value: 600 }],
tablet: [{ type: 'min-width', value: 601 }, { type: 'max-width', value: 960 }],
desktop: [{ type: 'min-width', value: 961 }],
}; // the default config ReactiveResponsiveState/the singleton use when none is passed
```
Only 6 of these 30 `type`s auto-append `px` to a numeric `value`
(`width`/`min-width`/`max-width`/`height`/`min-height`/`max-height`) —
everything else is `String(value)` verbatim, so e.g. `resolution` needs
an explicit unit string (`'2dppx'`, `'192dpi'`), not a bare number.
---
## 5. `ReactiveResponsiveState` + singleton + standalone helpers (root entry)
```ts
class ReactiveResponsiveState extends BaseResponsiveState {
constructor(config?: Record, options?: SetConfigOptions)
// config defaults to ResponsiveConfig (mobile/tablet/desktop) if omitted
}
const responsiveState: ReactiveResponsiveState; // module-level singleton, default config — what the framework adapters' zero-arg hooks read from
function createResponsiveState(config?, options?): ReactiveResponsiveState;
// An ISOLATED instance — for tests, SSR per-request isolation, or multiple independent responsive contexts.
// NOTE (section 8): the Vue/React adapters' zero-arg composables always read the global `responsiveState`
// singleton, NOT whatever createResponsiveState() instance you may have created — there's no way to point
// useResponsive()/useBreakpoints() at a custom instance.
function buildMediaQuery(conditions: MediaQueryConfig): string; // '' for an empty array
function toMediaQueryString(conditions: MediaQueryConfig): string; // alias for buildMediaQuery
function getResponsiveState(): T; // responsiveState.getState()
function getResponsiveMediaQueries(): Record; // responsiveState.getMediaQueries()
function setResponsiveConfig(config, options?): void; // responsiveState.setConfig() — mutates the singleton
function match(state: Record, map: Record, fallback?: T): T | undefined;
// Returns the first value in `map` whose key is true in `state`, in `map`'s OWN insertion order
// (not `state`'s or any configured breakpoint `order`) — fallback (or undefined) if nothing matches.
// const cols = match(state, { mobile: 1, tablet: 2, desktop: 4 })
```
---
## 6. `ContainerState` + `createContainerState` (root entry, and `/container`)
```ts
class ContainerState extends BaseResponsiveState {
constructor(element: Element, config: Record, options?: SetConfigOptions)
}
function createContainerState(element: Element, config: Record, options?: SetConfigOptions): ContainerState;
```
Same public API as `ReactiveResponsiveState` (inherited from
`BaseResponsiveState`) — `subscribe`/`on`/`syncCSSVars`/etc. all work
identically. Differences:
- **Only 6 condition types are actually evaluable**: `max-width`
(`w <= val`), `min-width` (`w >= val`), `max-height` (`h <= val`),
`min-height` (`h >= val`), `orientation` (`value === 'landscape' ? w > h : w <= h`),
`aspect-ratio` (parses a `"rw/rh"` string, tolerance `< 0.01`).
**Every other `MediaQueryCondition.type`** (`resolution`, `hover`,
`prefers-color-scheme`, `raw`, all 24 others) hits a `default: return
false` — TypeScript happily accepts them (same shared `MediaQueryConfig`
type as viewport queries) but the boolean silently stays `false`
forever, no warning.
- `getMediaQueries()` still generates full-vocabulary CSS strings via
the same `buildMediaQuery()` used for real matchMedia queries — so a
container config using an unevaluable type (e.g. `resolution`) produces
a plausible-looking CSS string from `getMediaQueries()` while the
actual JS boolean for that key never becomes anything but `false`.
- The JSDoc describes `getMediaQueries()`'s output as "`@container`
compatible" strings — they're bare condition lists (e.g.
`"(max-width: 300px)"`), not `@container` at-rules; wrap them yourself.
- If `ResizeObserver` is unsupported (or SSR), **no observer is created
at all** and every key is set `false` — unlike the viewport case, which
still does one synchronous `query.matches` read even when just
`hasMatchMedia()` is what's being checked (the two "unsupported"
fallbacks aren't symmetric — container gets zero evaluation ever,
viewport still gets one correct synchronous read whenever `matchMedia`
itself is available).
---
## 7. `subscribeMediaQuery(query, callback)` (root entry)
```ts
function subscribeMediaQuery(query: string, callback: (matches: boolean) => void): () => void;
```
Framework-agnostic, used internally by both `useMediaQuery` hooks.
Calls `callback` **immediately** with the current match state, then again
on every `'change'`. SSR/unsupported (checked via a **locally redefined**
`isSSR()` inside `media-query.ts` — see gotcha 6) → calls `callback(false)`
synchronously and returns a no-op cleanup.
---
## 8. Presets (`/presets`, also re-exported from root)
```ts
const TailwindPreset: Record; // xs ≤639, sm 640-767, md 768-1023, lg 1024-1279, xl 1280-1535, '2xl' ≥1536
const TailwindOrder = ['xs','sm','md','lg','xl','2xl'] as const;
const BootstrapPreset: Record; // xs ≤575, sm 576-767, md 768-991, lg 992-1199, xl 1200-1399, xxl ≥1400
const BootstrapOrder = ['xs','sm','md','lg','xl','xxl'] as const;
const AccessibilityPreset: Record;
// dark / light / reducedMotion / highContrast / lowContrast / noHover / coarsePointer / forcedColors / print
// — mutually INDEPENDENT (unlike Tailwind/Bootstrap's mutually exclusive ranges), several can be true at once
```
`TailwindPreset`/`BootstrapPreset` carry no `order` of their own — pass
`TailwindOrder`/`BootstrapOrder` explicitly via `SetConfigOptions.order`
for `isAbove`/`isBelow`/`between`/`current` to mean anything (object key
declaration order happens to match too, but that's incidental, not
type-enforced).
---
## 9. Vue adapter (`responsive-media/vue`)
```ts
function useResponsive = Record>(): T;
// Checks Vue `inject(RESPONSIVE_KEY, null)` FIRST; falls back to a lazily-created, module-level
// singleton reactive() mirror of the global `responsiveState` if nothing was provided.
interface BreakpointHelpers {
current: ComputedRef;
isAbove: (key: string) => boolean;
isBelow: (key: string) => boolean;
between: (from: string, to: string) => boolean;
}
function useBreakpoints(): BreakpointHelpers;
// Reads the module-level singleton DIRECTLY — does NOT consult inject(). See gotcha 8: this is
// inconsistent with useResponsive() above.
function useMediaQuery(query: string): Ref;
// Wraps subscribeMediaQuery(). Cleanup registered via onUnmounted() ONLY if getCurrentInstance()
// is truthy — see gotcha 9 for what happens otherwise.
function useContainerState(elementRef: Ref, config: Record, options?: SetConfigOptions): ResponsiveState;
// reactive(). Uses watchEffect(cleanup) — correctly re-runs (tearing down and recreating the
// ContainerState) whenever elementRef.value itself changes, e.g. across a v-if toggle.
const ResponsivePlugin: { install(app: App, config?: Record): void };
// If `config` is given, calls setResponsiveConfig(config) — mutates the GLOBAL responsiveState
// singleton (not something scoped to this one `app`). Then app.provide()s the same global
// reactive() mirror useResponsive()/useBreakpoints() already read from.
```
**The underlying reactive mirror is one process-wide singleton, not one
per Vue `App` instance** — `ensureVueState()` memoizes at module scope
(`let vueReactiveState: ResponsiveState | null = null`, assigned once,
ever, per loaded JS module). See gotcha 7 for the SSR multi-request
consequence.
---
## 10. React adapter (`responsive-media/react`)
```ts
function useResponsive = ResponsiveState>(): T;
// useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) — all backed by the SAME global
// `responsiveState` singleton the Vue adapter's fallback path also uses. No context/provider
// equivalent to Vue's inject() exists for this hook — always the global singleton.
interface BreakpointHelpers { current: string | null; isAbove(key): boolean; isBelow(key): boolean; between(from, to): boolean }
function useBreakpoints(): BreakpointHelpers; // plain values (not memoized refs) — re-render-driven, calls useResponsive() internally to subscribe
function useMediaQuery(query: string): boolean; // useState + useEffect(() => subscribeMediaQuery(...), [query])
function useContainerState(ref: RefObject, config: Record, options?: SetConfigOptions): Record;
// useEffect(..., []) — EMPTY deps, runs exactly once on mount. Explicit source comment: "config /
// options are treated as static after mount; memoize if needed." See gotcha 10 for the null-ref-at-mount case.
```
---
## 11. Consolidated gotcha list
1. **Writing directly to `this.state[key]` bypasses the reactivity
`Proxy` entirely — only writes through `this.proxy[key] =` trigger
`on()`/`subscribe()`/etc.** Both `ReactiveResponsiveState` and
`ContainerState` use direct `state[key] =` assignment for their
*initial* synchronous read (inside `setupSources()`), and for every
key that survives a `setConfig()` reconfiguration (`clearStateKeys`
deletes then `setupSources` silently repopulates, both via direct
assignment) — so per-key listeners (`on`, `onEnter`, `onLeave`,
`once`, `waitFor`) never see that transition; only `subscribe()`-level
listeners get the fresh snapshot via the one `flushNotify()` at the
end of `setConfig()`. Only genuine live `matchMedia` `'change'`
events and `ResizeObserver` callbacks go through `this.proxy[key] =`
and reach per-key listeners.
2. **`isAbove(key)` has no bounds guard for an unrecognized key, unlike
`isBelow`/`between`.** `isAbove` is
`ord.indexOf(cur ?? '') > ord.indexOf(key)`; a typo'd `key` makes
`ord.indexOf(key)` return `-1`, and since any valid index is `> -1`,
`isAbove('typo')` returns **`true`** whenever any breakpoint is
currently active. `isBelow`/`between` both explicitly guard
`curIdx !== -1 &&` and correctly return `false` in the same scenario.
3. `ContainerState` only evaluates 6 of the 30 declared
`MediaQueryCondition` types (`max-width`/`min-width`/`max-height`/
`min-height`/`orientation`/`aspect-ratio`) — every other type silently
stays `false` forever, with no compile-time or runtime warning (same
shared type used for both viewport and container configs) (section 6).
4. `ContainerState.getMediaQueries()` generates full-vocabulary CSS
strings via the same `buildMediaQuery()` viewport queries use — so it
can promise a condition (e.g. `resolution`) that the JS-side boolean
for that same key can never actually detect (section 6).
5. `buildMediaQuery`'s automatic `px` unit only applies to 6 "pixel-ish"
types; anything else numeric (e.g. `resolution`) is stringified
verbatim with no unit — pass an explicit unit string yourself
(section 4).
6. **Two different `isSSR()` implementations exist in the same package.**
`utils.ts`'s is `typeof window === 'undefined'` only. `media-query.ts`
has its own private, stricter one:
`typeof window === 'undefined' || typeof window.matchMedia !== 'function'`.
In an environment where `window` exists but `matchMedia` doesn't,
`syncCSSVars()`/`emitDOMEvents()` (which use `utils.isSSR()`) would
proceed and touch `document`, while `subscribeMediaQuery()` in the
exact same runtime bails out and always reports `false`.
7. **`ResponsivePlugin` and Vue's zero-arg `useResponsive()`/
`useBreakpoints()` fallback path are all backed by ONE process-wide
singleton**, not one per Vue `App`. Two `app.use(ResponsivePlugin,
differentConfig)` calls in the same process (e.g. handling two
concurrent SSR requests) clobber each other's config and share the
identical reactive object — `createResponsiveState()` supports real
per-instance isolation, but nothing in the Vue/React adapters lets you
point a hook/composable at a custom instance instead of the global
singleton (sections 5, 9, 10).
8. **`useBreakpoints()` (Vue) ignores `inject()`/DI entirely, while
`useResponsive()` honors it** — an inconsistency between the two. If
an app manually `app.provide(RESPONSIVE_KEY, customState)` (bypassing
`ResponsivePlugin`), `useResponsive()` picks it up but `useBreakpoints()`
in the same component keeps silently reading the unrelated global
singleton (section 9).
9. **Vue's `useMediaQuery()` can leak its `matchMedia` listener with no
way to clean it up if called outside an active component instance.**
`onUnmounted(off)` is only wired when `getCurrentInstance()` is
truthy, and the returned `Ref` never exposes `off` itself —
calling it from a plain factory function, a Pinia store setup, or any
context without an active component instance leaks the listener for
the app's lifetime (section 9).
10. **React's `useContainerState()` sets up its `ResizeObserver` exactly
once, via `useEffect(..., [])`.** If `ref.current` is `null` when
this effect runs (the target is behind a later-mounted conditional,
portal, or async child), the observer is **never** created for that
component instance's lifetime — refs aren't reactive, so there's no
retry. Vue's equivalent (`watchEffect`) correctly re-runs whenever
the templated ref actually attaches (section 10).
11. **Neither the Vue nor the React adapter has any automated test
coverage** — the package's test suite exercises only the
framework-agnostic core (`ReactiveResponsiveState`, `ContainerState`,
presets, `subscribeMediaQuery`, `match`); every composable/hook
signature and behavior above (including gotchas 7–10) is unverified
by CI.
12. `hydrate(initialState)` silently drops any key not already present
in the live state (e.g. after a `setConfig()` narrowed the active
keyset) — no error, no warning (section 3.4).
13. **Before 2.0.0**, the Vue adapter was re-exported from the package
root, meaning importing bare `'responsive-media'` unconditionally
pulled in `@vue/runtime-core` with no guaranteed-resolvable
dependency backing it — fixed by moving it to `/vue` (section 1). If
you encounter example code importing `useResponsive`/`useBreakpoints`/
`useMediaQuery`/`useContainerState`/`ResponsivePlugin` from the bare
`'responsive-media'` specifier, it's targeting the pre-2.0 API —
update it to `'responsive-media/vue'`.