# 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'`.