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
npm install responsive-media
- 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
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();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.
// (min-width: 768px) and (max-width: 1023px)
[
{ type: 'min-width', value: 768 },
{ type: 'max-width', value: 1023 },
]// (max-width: 600px), (orientation: portrait) and (max-width: 1024px)
[
[{ type: 'max-width', value: 600 }],
[{ type: 'orientation', value: 'portrait' }, { type: 'max-width', value: 1024 }],
]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 }]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 |
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)' }| Key | Range |
|---|---|
mobile |
≤ 600px |
tablet |
601 – 960px |
desktop |
≥ 961px |
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 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();max-width, min-width, max-height, min-height, orientation, aspect-ratio
All methods below are available on both ReactiveResponsiveState and ContainerState.
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(); // unsubscribeFires 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();Fires only on false → true transitions. Skips the initial value. Never debounced.
state.onEnter('mobile', () => initMobileMenu());Fires only on true → false transitions. Skips the initial value. Never debounced.
state.onLeave('mobile', () => destroyMobileMenu());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);
});Fires on the next global state change, then auto-unsubscribes. Affected by debounce.
state.onNextChange((s) => console.log('first change:', s));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}`);
});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);These helpers require a breakpoint order — either set via setConfig / createResponsiveState options, or derived from config key insertion order.
Returns the first active breakpoint key in order, or null.
if (state.current === 'mobile') showDrawer();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') // → falsetrue when the current breakpoint comes before key in the order.
state.isBelow('md') // → true (current = 'sm')true when the current breakpoint is between from and to (inclusive).
state.between('sm', 'lg') // → true (current = 'md')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 |
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 |
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);Returns the generated CSS media query strings for each breakpoint key.
const mq = state.getMediaQueries();
// { mobile: '(max-width: 600px)', desktop: '(min-width: 961px)' }Returns a stable snapshot of the current state. Same reference between changes — safe for React's useSyncExternalStore.
Returns the configured breakpoint order array (or empty array if not set).
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 });Removes all matchMedia / ResizeObserver listeners, clears all subscribers, and cancels any pending debounce timer.
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)"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');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(); // cleanupImport from responsive-media/presets or from the main entry point.
| Key | Range |
|---|---|
mobile |
≤ 600px |
tablet |
601 – 960px |
desktop |
≥ 961px |
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] });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] });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());Import from responsive-media (main entry) or responsive-media directly — Vue composables are included in the main bundle.
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');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>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.
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>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>Import from responsive-media/react.
import { useResponsive, useBreakpoints, useMediaQuery, useContainerState } from 'responsive-media/react';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 />;
}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.
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>;
}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.
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>();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>();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);| 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 |
| 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 |
| 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 |
| Export | Description |
|---|---|
ContainerState |
Class for element container queries |
createContainerState |
Factory function |
MIT
Danil Lisin aka Macrulez
GitHub: macrulezru · Website: macrulez.ru