responsive-media
v1.2.0
Published
A utility for reactive state based on CSS media queries. Includes integration with Vue 3 (Composition API) and React 18+.
Downloads
149
Maintainers
Readme
responsive-media
A utility for creating reactive boolean state from CSS media queries and element dimensions. Useful when you need more than CSS — when you want to imperatively react to viewport or container changes in JavaScript.
- Viewport breakpoints — backed by
window.matchMedia - Container queries — backed by
ResizeObserver(JS-side evaluation) - Vue 3 and React 18+ adapters included
- SSR-safe — falls back to
falseon the server - Framework-agnostic core — works with Vanilla JS, signals libraries, or any other framework
Installation
npm install responsive-mediaTable of Contents
- Quick Start
- Config format — MediaQueryConfig
- Global singleton
- createResponsiveState — isolated instances
- ContainerState — element container queries
- Subscription API
- Ordered breakpoint helpers
- Utilities
- Presets
- Vue 3 integration
- React 18+ integration
- TypeScript helpers
- SSR / hydration
- Exported API reference
Quick Start
import { responsiveState, setResponsiveConfig } from 'responsive-media';
setResponsiveConfig({
mobile: [{ type: 'max-width', value: 767 }],
tablet: [{ type: 'min-width', value: 768 }, { type: 'max-width', value: 1023 }],
desktop: [{ type: 'min-width', value: 1024 }],
});
// Read current state
console.log(responsiveState.proxy.mobile); // true / false
// Subscribe to changes
const stop = responsiveState.subscribe((state) => {
console.log('desktop:', state.desktop);
});
// Cleanup
stop();Config format — MediaQueryConfig
Each breakpoint is described by a MediaQueryConfig — an array of conditions that are combined with AND, or a nested array of groups combined with OR.
AND (flat array)
// (min-width: 768px) and (max-width: 1023px)
[
{ type: 'min-width', value: 768 },
{ type: 'max-width', value: 1023 },
]OR (nested array)
// (max-width: 600px), (orientation: portrait) and (max-width: 1024px)
[
[{ type: 'max-width', value: 600 }],
[{ type: 'orientation', value: 'portrait' }, { type: 'max-width', value: 1024 }],
]Raw media type
Use type: 'raw' to insert a value verbatim — useful for media types like print or screen:
// Matches 'print' media type
[{ type: 'raw', value: 'print' }]
// screen and (max-width: 600px)
[{ type: 'raw', value: 'screen' }, { type: 'max-width', value: 600 }]Supported condition types
| type | Example value | Generated query |
|-------------------------|-------------------|-----------------------------------|
| min-width | 768 | (min-width: 768px) |
| max-width | 1023 | (max-width: 1023px) |
| min-height | 600 | (min-height: 600px) |
| max-height | 900 | (max-height: 900px) |
| orientation | 'portrait' | (orientation: portrait) |
| aspect-ratio | '16/9' | (aspect-ratio: 16/9) |
| prefers-color-scheme | 'dark' | (prefers-color-scheme: dark) |
| prefers-reduced-motion| 'reduce' | (prefers-reduced-motion: reduce)|
| prefers-contrast | 'more' | (prefers-contrast: more) |
| hover | 'none' | (hover: none) |
| pointer | 'coarse' | (pointer: coarse) |
| forced-colors | 'active' | (forced-colors: active) |
| resolution | '2dppx' | (resolution: 2dppx) |
| display-mode | 'standalone' | (display-mode: standalone) |
| raw | 'print' | print (verbatim) |
| … and more | | |
Global singleton
The library exports a pre-configured singleton responsiveState initialized with the default ResponsiveConfig (mobile / tablet / desktop).
import { responsiveState, setResponsiveConfig, getResponsiveMediaQueries } from 'responsive-media';
// Re-configure the singleton
setResponsiveConfig(
{
sm: [{ type: 'max-width', value: 767 }],
lg: [{ type: 'min-width', value: 1024 }],
},
{
order: ['sm', 'lg'], // for isAbove/isBelow/between
debounce: 50, // ms, throttle subscribe() listeners
}
);
// Read the current state snapshot
const { sm, lg } = responsiveState.getState();
// Direct proxy access (live, non-debounced)
console.log(responsiveState.proxy.sm);
// Get the generated CSS strings
const mq = getResponsiveMediaQueries();
// { sm: '(max-width: 767px)', lg: '(min-width: 1024px)' }Default config (ResponsiveConfig)
| Key | Range |
|-----------|--------------|
| mobile | ≤ 600px |
| tablet | 601 – 960px |
| desktop | ≥ 961px |
createResponsiveState — isolated instances
Create independent instances — useful for per-request SSR, multiple independent contexts, or testing:
import { createResponsiveState, TailwindPreset, TailwindOrder } from 'responsive-media';
const layoutState = createResponsiveState(TailwindPreset, {
order: [...TailwindOrder],
});
const themeState = createResponsiveState({
dark: [{ type: 'prefers-color-scheme', value: 'dark' }],
reducedMotion: [{ type: 'prefers-reduced-motion', value: 'reduce' }],
});
layoutState.subscribe((s) => console.log('layout:', s));
themeState.subscribe((s) => console.log('theme:', s));
// Cleanup when done (e.g. per-request SSR)
layoutState.destroy();ContainerState — element container queries
ContainerState tracks an element's dimensions via ResizeObserver and evaluates breakpoint conditions in JavaScript — the same concept as CSS Container Queries, but in JS.
The API is identical to ReactiveResponsiveState — all subscription methods work the same way.
import { createContainerState } from 'responsive-media/container';
// or: import { createContainerState } from 'responsive-media';
const card = document.querySelector('.card')!;
const cardState = createContainerState(card, {
compact: [{ type: 'max-width', value: 300 }],
normal: [{ type: 'min-width', value: 301 }, { type: 'max-width', value: 599 }],
wide: [{ type: 'min-width', value: 600 }],
}, {
order: ['compact', 'normal', 'wide'],
});
// Reactive class toggling
cardState.on('compact', (v) => card.classList.toggle('card--compact', v));
// Sync CSS custom properties: --card-compact: 1; --card-wide: 0; …
cardState.syncCSSVars({ prefix: '--card-' });
// Get @container-compatible query strings
const strings = cardState.getMediaQueries();
// { compact: '(max-width: 300px)', wide: '(min-width: 600px)' }
// Cleanup
cardState.destroy();Supported condition types for ContainerState
max-width, min-width, max-height, min-height, orientation, aspect-ratio
Subscription API
All methods below are available on both ReactiveResponsiveState and ContainerState.
subscribe(listener) → unsubscribe
Fires immediately with the current state, then on every change. Affected by debounce.
const stop = state.subscribe((s) => {
document.body.dataset.bp = Object.keys(s).filter(k => s[k]).join(' ');
});
stop(); // unsubscribeon(key, callback) → unsubscribe
Fires immediately with the current value for key, then on every change. Never debounced.
const off = state.on('mobile', (matches) => {
header.classList.toggle('header--mobile', matches);
});
off();onEnter(key, callback) → unsubscribe
Fires only on false → true transitions. Skips the initial value. Never debounced.
state.onEnter('mobile', () => initMobileMenu());onLeave(key, callback) → unsubscribe
Fires only on true → false transitions. Skips the initial value. Never debounced.
state.onLeave('mobile', () => destroyMobileMenu());once(key, callback) → unsubscribe
Fires on the next change to key, then auto-unsubscribes. Does not fire for the current value. Never debounced.
state.once('mobile', (matches) => {
console.log('mobile changed to:', matches);
});onNextChange(callback) → unsubscribe
Fires on the next global state change, then auto-unsubscribes. Affected by debounce.
state.onNextChange((s) => console.log('first change:', s));onBreakpointChange(callback) → unsubscribe
Fires when the active breakpoint changes (i.e. current changes), providing from and to. Affected by debounce.
state.onBreakpointChange((from, to) => {
console.log(`breakpoint: ${from} → ${to}`);
});waitFor(key, expectedValue?) → Promise
Returns a Promise that resolves when key reaches expectedValue (default true). Resolves immediately if already met. Never debounced.
await state.waitFor('desktop');
initDesktopChart();
// Wait for mobile to become false
await state.waitFor('mobile', false);Ordered breakpoint helpers
These helpers require a breakpoint order — either set via setConfig / createResponsiveState options, or derived from config key insertion order.
state.current — getter
Returns the first active breakpoint key in order, or null.
if (state.current === 'mobile') showDrawer();state.isAbove(key) → boolean
true when the current breakpoint comes after key in the order.
// order: ['xs', 'sm', 'md', 'lg', 'xl']
// current = 'lg'
state.isAbove('sm') // → true
state.isAbove('xl') // → falsestate.isBelow(key) → boolean
true when the current breakpoint comes before key in the order.
state.isBelow('md') // → true (current = 'sm')state.between(from, to) → boolean
true when the current breakpoint is between from and to (inclusive).
state.between('sm', 'lg') // → true (current = 'md')Utilities
syncCSSVars(options?) → stop
Syncs all breakpoint keys to CSS custom properties (1 / 0) on document.documentElement (or a custom element). Automatically removes properties for keys removed during a config change.
const stop = state.syncCSSVars({ element: document.body, prefix: '--bp-' });
// → --bp-mobile: 1; --bp-desktop: 0; …
stop(); // cleanupOptions:
| Option | Default | Description |
|-----------|--------------------------|----------------------------------|
| element | document.documentElement | Target HTML element |
| prefix | '--responsive-' | CSS custom property name prefix |
emitDOMEvents(target?, options?) → stop
Dispatches DOM CustomEvents on target whenever breakpoints change:
responsive:change— fires on any state change;event.detailis the full state snapshotresponsive:mobile:enter— fires whenmobilebecomestrueresponsive:mobile:leave— fires whenmobilebecomesfalse
const stop = state.emitDOMEvents(document, { prefix: 'bp:' });
document.addEventListener('bp:change', (e) => console.log(e.detail));
document.addEventListener('bp:mobile:enter', () => initDrawer());
document.addEventListener('bp:desktop:leave', () => destroyDesktopChart());
stop();Options:
| Option | Default | Description |
|----------|-----------------|--------------------------|
| prefix | 'responsive:' | Custom event name prefix |
toSignal(key, factory) → Signal
Binds a breakpoint key to a writable signal from any signals library. The signal is kept in sync via on().
// @preact/signals-core
import { signal } from '@preact/signals-core';
const isMobile = state.toSignal('mobile', signal);
isMobile.value; // reactive boolean
// Angular signal
import { signal } from '@angular/core';
const isMobile = state.toSignal('mobile', signal);
// Vue ref
import { ref } from 'vue';
const isMobile = state.toSignal('mobile', ref);getMediaQueries() → Record<string, string>
Returns the generated CSS media query strings for each breakpoint key.
const mq = state.getMediaQueries();
// { mobile: '(max-width: 600px)', desktop: '(min-width: 961px)' }getState<T>() → T
Returns a stable snapshot of the current state. Same reference between changes — safe for React's useSyncExternalStore.
getOrder() → string[]
Returns the configured breakpoint order array (or empty array if not set).
hydrate(initialState) — SSR hydration
Sets initial state from a server-side snapshot to prevent layout shift. Only updates keys that exist in the current config.
// On the server, serialize state and pass to the client:
state.hydrate({ mobile: false, tablet: false, desktop: true });destroy()
Removes all matchMedia / ResizeObserver listeners, clears all subscribers, and cancels any pending debounce timer.
toMediaQueryString(conditions) — standalone utility
Converts a MediaQueryConfig to a CSS media query string. Useful for CSS-in-JS or debugging.
import { toMediaQueryString } from 'responsive-media';
toMediaQueryString([{ type: 'min-width', value: 768 }, { type: 'max-width', value: 1024 }])
// → "(min-width: 768px) and (max-width: 1024px)"
toMediaQueryString([[{ type: 'max-width', value: 600 }], [{ type: 'orientation', value: 'portrait' }]])
// → "(max-width: 600px), (orientation: portrait)"match(state, map, fallback?) — standalone utility
Returns the first value in map whose key is true in state. Priority follows map insertion order.
import { match } from 'responsive-media';
import { responsiveState } from 'responsive-media';
const cols = match(responsiveState.proxy, { mobile: 1, tablet: 2, desktop: 4 });
const View = match(responsiveState.proxy, { mobile: MobileMenu, desktop: DesktopNav });
const label = match(responsiveState.proxy, { sm: 'Compact', lg: 'Full' }, 'Default');subscribeMediaQuery(query, callback) — standalone utility
Low-level reactive wrapper around a single raw CSS media query string. Framework-agnostic — the Vue and React adapters use this internally.
import { subscribeMediaQuery } from 'responsive-media';
const off = subscribeMediaQuery('(prefers-color-scheme: dark)', (matches) => {
document.body.classList.toggle('dark', matches);
});
off(); // cleanupPresets
Import from responsive-media/presets or from the main entry point.
ResponsiveConfig (default)
| Key | Range |
|-----------|--------------|
| mobile | ≤ 600px |
| tablet | 601 – 960px |
| desktop | ≥ 961px |
TailwindPreset + TailwindOrder
Mutually exclusive Tailwind CSS v3/v4 breakpoints:
| Key | Range |
|-------|----------------|
| xs | ≤ 639px |
| sm | 640 – 767px |
| md | 768 – 1023px |
| lg | 1024 – 1279px |
| xl | 1280 – 1535px |
| 2xl | ≥ 1536px |
import { createResponsiveState, TailwindPreset, TailwindOrder } from 'responsive-media';
const state = createResponsiveState(TailwindPreset, { order: [...TailwindOrder] });BootstrapPreset + BootstrapOrder
Mutually exclusive Bootstrap 5 breakpoints:
| Key | Range |
|-------|----------------|
| xs | ≤ 575px |
| sm | 576 – 767px |
| md | 768 – 991px |
| lg | 992 – 1199px |
| xl | 1200 – 1399px |
| xxl | ≥ 1400px |
import { createResponsiveState, BootstrapPreset, BootstrapOrder } from 'responsive-media';
const state = createResponsiveState(BootstrapPreset, { order: [...BootstrapOrder] });AccessibilityPreset
User-preference media queries. Multiple keys can be true simultaneously.
| Key | Matches when … |
|-----------------|-----------------------------------------|
| dark | prefers-color-scheme: dark |
| light | prefers-color-scheme: light |
| reducedMotion | prefers-reduced-motion: reduce |
| highContrast | prefers-contrast: more |
| lowContrast | prefers-contrast: less |
| noHover | hover: none (touch / stylus devices) |
| coarsePointer | pointer: coarse (finger-sized input) |
| forcedColors | forced-colors: active (Windows HCM) |
| print | print media type |
import { createResponsiveState, AccessibilityPreset } from 'responsive-media';
const a11y = createResponsiveState(AccessibilityPreset);
a11y.onEnter('dark', () => applyDarkTheme());
a11y.onEnter('reducedMotion', () => disableAnimations());
a11y.onEnter('print', () => hideNonPrintable());Vue 3 integration
Import from responsive-media (main entry) or responsive-media directly — Vue composables are included in the main bundle.
Plugin registration
import { createApp } from 'vue';
import { ResponsivePlugin } from 'responsive-media';
const app = createApp(App);
app.use(ResponsivePlugin, {
sm: [{ type: 'max-width', value: 767 }],
lg: [{ type: 'min-width', value: 1024 }],
});
app.mount('#app');useResponsive<T>() — reactive state object
Returns the Vue reactive responsive state. Reactive in templates and computed properties.
<script setup lang="ts">
import { useResponsive } from 'responsive-media';
type MyState = { sm: boolean; lg: boolean };
const state = useResponsive<MyState>();
</script>
<template>
<MobileNav v-if="state.sm" />
<DesktopNav v-else />
</template>useBreakpoints() — ordered helpers
Returns reactive ordered breakpoint helpers. All methods react to viewport changes in templates.
<script setup>
import { useBreakpoints } from 'responsive-media';
const { current, isAbove, isBelow, between } = useBreakpoints();
</script>
<template>
<span>Current: {{ current }}</span>
<DesktopNav v-if="isAbove('sm')" />
<MobileNav v-else />
<TabletOnly v-if="between('sm', 'lg')" />
</template>
currentis aComputedRef<string | null>.isAbove,isBelow,betweenare plain functions — reactive because they read from the Vue reactive state.
useMediaQuery(query) — single raw query
Returns a Ref<boolean> for a raw CSS media query string. Cleans up automatically on onUnmounted.
<script setup>
import { useMediaQuery } from 'responsive-media';
const isDark = useMediaQuery('(prefers-color-scheme: dark)');
const canHover = useMediaQuery('(hover: hover)');
</script>
<template>
<DarkTheme v-if="isDark" />
</template>useContainerState(elementRef, config, options?) — container queries
Tracks an element's dimensions and returns a reactive state object. Sets up and tears down the ResizeObserver automatically via watchEffect.
<script setup>
import { useTemplateRef } from 'vue';
import { useContainerState } from 'responsive-media';
const cardRef = useTemplateRef('card');
const cardState = useContainerState(cardRef, {
compact: [{ type: 'max-width', value: 300 }],
wide: [{ type: 'min-width', value: 600 }],
});
</script>
<template>
<div ref="card">
<CompactLayout v-if="cardState.compact" />
<WideLayout v-else-if="cardState.wide" />
<DefaultLayout v-else />
</div>
</template>React 18+ integration
Import from responsive-media/react.
import { useResponsive, useBreakpoints, useMediaQuery, useContainerState } from 'responsive-media/react';useResponsive<T>() — reactive state
Returns the current responsive state. Re-renders only when state changes. Uses useSyncExternalStore internally.
import { useResponsive } from 'responsive-media/react';
type MyState = { sm: boolean; lg: boolean };
function App() {
const { sm, lg } = useResponsive<MyState>();
return sm ? <MobileNav /> : <DesktopNav />;
}useBreakpoints() — ordered helpers
Returns ordered breakpoint helpers. Re-renders when the responsive state changes.
import { useBreakpoints } from 'responsive-media/react';
function Nav() {
const { current, isAbove, isBelow, between } = useBreakpoints();
return (
<>
<span>Current: {current}</span>
{isAbove('sm') ? <DesktopNav /> : <MobileNav />}
{between('sm', 'lg') && <TabletBanner />}
</>
);
}Unlike Vue,
currentis a plainstring | null(not a ref). Re-renders are triggered byuseSyncExternalStore.
useMediaQuery(query) — single raw query
Returns a boolean that tracks a raw CSS media query string. SSR-safe (returns false on the server).
import { useMediaQuery } from 'responsive-media/react';
function ThemeToggle() {
const isDark = useMediaQuery('(prefers-color-scheme: dark)');
const canHover = useMediaQuery('(hover: hover)');
return <button className={isDark ? 'dark' : 'light'}>Toggle</button>;
}useContainerState(ref, config, options?) — container queries
Tracks an element's dimensions and returns a state object. Sets up and tears down ResizeObserver via useEffect.
import { useRef } from 'react';
import { useContainerState } from 'responsive-media/react';
function Card() {
const ref = useRef<HTMLDivElement>(null);
const { compact, wide } = useContainerState(ref, {
compact: [{ type: 'max-width', value: 300 }],
wide: [{ type: 'min-width', value: 600 }],
});
return (
<div ref={ref}>
{compact ? <CompactLayout /> : wide ? <WideLayout /> : <DefaultLayout />}
</div>
);
}
configandoptionsare treated as static after mount. Wrap inuseMemoif they change.
TypeScript helpers
ConfigToState<T>
Derives a boolean-state type from a config object:
import type { ConfigToState, MediaQueryConfig } from 'responsive-media';
const config = {
sm: [{ type: 'min-width', value: 640 }],
lg: [{ type: 'min-width', value: 1024 }],
} satisfies Record<string, MediaQueryConfig>;
type MyState = ConfigToState<typeof config>;
// → { sm: boolean; lg: boolean }
const { sm, lg } = responsiveState.getState<MyState>();Generic useResponsive<T>()
Both Vue and React adapters accept a generic type parameter to narrow the returned state:
type AppState = { mobile: boolean; tablet: boolean; desktop: boolean };
const state = useResponsive<AppState>();SSR / hydration
All APIs are SSR-safe — they check for window and matchMedia availability before use and fall back to false on the server.
For hydration (preventing layout shift):
// Server: serialize the expected initial state
const initialState = { mobile: false, tablet: false, desktop: true };
// Client: hydrate before the first render
import { responsiveState } from 'responsive-media';
responsiveState.hydrate(initialState);Exported API reference
Main entry (responsive-media)
| Export | Type | Description |
|---------------------------|--------------------------|-------------------------------------------------------|
| responsiveState | ReactiveResponsiveState| Global singleton, default ResponsiveConfig |
| setResponsiveConfig | function | Reconfigure the global singleton |
| getResponsiveState | function | Get state snapshot from global singleton |
| getResponsiveMediaQueries | function | Get CSS query strings from global singleton |
| createResponsiveState | function | Create an isolated ReactiveResponsiveState instance |
| createContainerState | function | Create a ContainerState for an element |
| toMediaQueryString | function | Convert MediaQueryConfig to CSS string |
| match | function | Pick a value by first matching breakpoint key |
| subscribeMediaQuery | function | Subscribe to a raw CSS media query string |
| ResponsiveConfig | const | Default mobile/tablet/desktop breakpoints |
| BaseResponsiveState | class | Abstract base (for extension) |
| ReactiveResponsiveState | class | Viewport state (matchMedia-backed) |
| ContainerState | class | Element container state (ResizeObserver-backed) |
| ResponsivePlugin | Vue plugin | Vue app plugin |
| useResponsive | Vue composable | Reactive state object |
| useBreakpoints | Vue composable | Ordered breakpoint helpers |
| useMediaQuery | Vue composable | Single raw media query |
| useContainerState | Vue composable | Element container queries |
| ConfigToState | type | Derives state type from config |
| MediaQueryConfig | type | Config entry type |
| MediaQueryCondition | type | Single condition type |
| ResponsiveState | type | Record<string, boolean> |
| SetConfigOptions | type | Options for setConfig / createResponsiveState |
| BreakpointHelpers | type | Return type of useBreakpoints |
React entry (responsive-media/react)
| Export | Description |
|---------------------|----------------------------------------------|
| useResponsive | State hook (useSyncExternalStore) |
| useBreakpoints | Ordered breakpoint helpers hook |
| useMediaQuery | Single raw media query hook |
| useContainerState | Element container queries hook |
| BreakpointHelpers | Type for useBreakpoints return value |
Presets entry (responsive-media/presets)
| Export | Description |
|----------------------|-----------------------------------------|
| TailwindPreset | Tailwind CSS v3/v4 breakpoints |
| TailwindOrder | Ordered key array for TailwindPreset |
| BootstrapPreset | Bootstrap 5 breakpoints |
| BootstrapOrder | Ordered key array for BootstrapPreset |
| AccessibilityPreset| User-preference media queries |
Container entry (responsive-media/container)
| Export | Description |
|-----------------------|----------------------------------------------|
| ContainerState | Class for element container queries |
| createContainerState| Factory function |
License
MIT
Author
Danil Lisin aka Macrulez
GitHub: macrulezru · Website: macrulez.ru
