responsive-media 1.0.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,141 +1,868 @@
1
- npm install responsive-media
2
-
3
- # responsive-media
4
-
5
- A utility for reactive state based on CSS media queries. Includes integration with Vue 3 (Composition API).
6
-
7
- ## Installation
8
-
9
- ```
10
- npm install responsive-media
11
- ```
12
-
13
- ## Usage without Vue
14
-
15
- ### Getting the state
16
-
17
- ```ts
18
- import { responsiveState } from 'responsive-media';
19
-
20
- // Get the current state:
21
- const { mobile, tablet, desktop } = responsiveState.proxy;
22
-
23
- console.log('isMobile:', mobile);
24
- console.log('isTablet:', tablet);
25
- console.log('isDesktop:', desktop);
26
- ```
27
-
28
- ### Subscribing to changes
29
-
30
- ```ts
31
- // Subscribe to state changes:
32
- const unsubscribe = responsiveState.subscribe((state) => {
33
- console.log('State changed:', state);
34
- // state.mobile, state.tablet, state.desktop
35
- });
36
-
37
- // To unsubscribe:
38
- unsubscribe();
39
- ```
40
-
41
- ## Usage with Vue 3 (Composition API)
42
-
43
- ### Plugin registration
44
-
45
- ```ts
46
- import { createApp } from 'vue';
47
- import { ResponsivePlugin, ResponsiveConfig } from 'responsive-media';
48
- import App from './App.vue';
49
-
50
- const app = createApp(App);
51
-
52
- // Use default breakpoints:
53
- app.use(ResponsivePlugin);
54
-
55
- // Or provide your own breakpoints (now you can combine conditions):
56
- app.use(ResponsivePlugin, {
57
- ...ResponsiveConfig, // keep default breakpoints
58
- myCustom: [
59
- { type: 'min-width', value: 1200 },
60
- { type: 'aspect-ratio', value: '16/9' },
61
- ], // add your own with multiple conditions
62
- mobile: [
63
- { type: 'max-width', value: 500 },
64
- { type: 'orientation', value: 'portrait' },
65
- ], // override default with multiple conditions
66
- });
67
-
68
- app.mount('#app');
69
- ```
70
-
71
- ### Usage in a component
72
-
73
- ```ts
74
- <script setup lang="ts">
75
- import { useResponsive } from 'responsive-media';
76
-
77
- const responsive = useResponsive();
78
-
79
- // responsive.mobile, responsive.tablet, responsive.desktop, etc.
80
- </script>
81
- ```
82
-
83
- ## Customizing breakpoints
84
-
85
- You can override or add your own breakpoints using the setResponsiveConfig function. Now each breakpoint can be an array of conditions (they will be combined with and):
86
-
87
- ```ts
88
- import { setResponsiveConfig, ResponsiveConfig } from 'responsive-media';
89
-
90
- setResponsiveConfig({
91
- ...ResponsiveConfig, // keep default
92
- myCustom: [
93
- { type: 'min-width', value: 1200 },
94
- { type: 'aspect-ratio', value: '16/9' },
95
- ], // add your own breakpoint with multiple conditions
96
- mobile: [
97
- { type: 'max-width', value: 500 },
98
- { type: 'orientation', value: 'portrait' },
99
- ], // override default with multiple conditions
100
- });
101
- ```
102
-
103
-
104
- ## Getting CSS media query strings
105
-
106
- You can get the generated CSS media query strings for each breakpoint:
107
-
108
- ```ts
109
- import { getResponsiveMediaQueries } from 'responsive-media';
110
-
111
- const mediaQueries = getResponsiveMediaQueries();
112
- console.log(mediaQueries);
113
- // {
114
- // mobile: '(max-width: 767px)',
115
- // tablet: '(min-width: 768px) and (max-width: 1023px)',
116
- // desktop: '(min-width: 1024px)'
117
- // }
118
- ```
119
-
120
- ## Exported entities
121
- - responsiveState
122
- - ResponsiveConfig
123
- - setResponsiveConfig
124
- - ResponsivePlugin (Vue)
125
- - useResponsive (Vue)
126
- - getResponsiveMediaQueries
127
-
128
- ## Breakpoint config format
129
-
130
- Each breakpoint is now described by an array of conditions (MediaQueryConfig = MediaQueryCondition[]), where each condition is an object with type and value. All conditions within a breakpoint are combined with and (e.g., `(max-width: 600px) and (aspect-ratio: 16/9)`).
131
-
132
- ## Author
133
-
134
- Danil Lisin Vladimirovich aka Macrulez
135
-
136
- GitHub: [macrulezru](https://github.com/macrulezru)
137
-
138
- Website: [macrulez.ru](https://macrulez.ru/)
139
-
140
- ## License
141
- MIT
1
+ # responsive-media
2
+
3
+ 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.
4
+
5
+ - **Viewport breakpoints** backed by `window.matchMedia`
6
+ - **Container queries** — backed by `ResizeObserver` (JS-side evaluation)
7
+ - **Vue 3** and **React 18+** adapters included
8
+ - **SSR-safe** — falls back to `false` on the server
9
+ - **Framework-agnostic** core — works with Vanilla JS, signals libraries, or any other framework
10
+
11
+ ## Installation
12
+
13
+ ```
14
+ npm install responsive-media
15
+ ```
16
+
17
+ ---
18
+
19
+ ## Table of Contents
20
+
21
+ 1. [Quick Start](#quick-start)
22
+ 2. [Config format — MediaQueryConfig](#config-format--mediaqueryconfig)
23
+ 3. [Global singleton](#global-singleton)
24
+ 4. [createResponsiveState — isolated instances](#createresponsivestate--isolated-instances)
25
+ 5. [ContainerState — element container queries](#containerstate--element-container-queries)
26
+ 6. [Subscription API](#subscription-api)
27
+ 7. [Ordered breakpoint helpers](#ordered-breakpoint-helpers)
28
+ 8. [Utilities](#utilities)
29
+ 9. [Presets](#presets)
30
+ 10. [Vue 3 integration](#vue-3-integration)
31
+ 11. [React 18+ integration](#react-18-integration)
32
+ 12. [TypeScript helpers](#typescript-helpers)
33
+ 13. [SSR / hydration](#ssr--hydration)
34
+ 14. [Exported API reference](#exported-api-reference)
35
+
36
+ ---
37
+
38
+ ## Quick Start
39
+
40
+ ```ts
41
+ import { responsiveState, setResponsiveConfig } from 'responsive-media';
42
+
43
+ setResponsiveConfig({
44
+ mobile: [{ type: 'max-width', value: 767 }],
45
+ tablet: [{ type: 'min-width', value: 768 }, { type: 'max-width', value: 1023 }],
46
+ desktop: [{ type: 'min-width', value: 1024 }],
47
+ });
48
+
49
+ // Read current state
50
+ console.log(responsiveState.proxy.mobile); // true / false
51
+
52
+ // Subscribe to changes
53
+ const stop = responsiveState.subscribe((state) => {
54
+ console.log('desktop:', state.desktop);
55
+ });
56
+
57
+ // Cleanup
58
+ stop();
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Config format MediaQueryConfig
64
+
65
+ 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**.
66
+
67
+ ### AND (flat array)
68
+
69
+ ```ts
70
+ // (min-width: 768px) and (max-width: 1023px)
71
+ [
72
+ { type: 'min-width', value: 768 },
73
+ { type: 'max-width', value: 1023 },
74
+ ]
75
+ ```
76
+
77
+ ### OR (nested array)
78
+
79
+ ```ts
80
+ // (max-width: 600px), (orientation: portrait) and (max-width: 1024px)
81
+ [
82
+ [{ type: 'max-width', value: 600 }],
83
+ [{ type: 'orientation', value: 'portrait' }, { type: 'max-width', value: 1024 }],
84
+ ]
85
+ ```
86
+
87
+ ### Raw media type
88
+
89
+ Use `type: 'raw'` to insert a value verbatim — useful for media types like `print` or `screen`:
90
+
91
+ ```ts
92
+ // Matches 'print' media type
93
+ [{ type: 'raw', value: 'print' }]
94
+
95
+ // screen and (max-width: 600px)
96
+ [{ type: 'raw', value: 'screen' }, { type: 'max-width', value: 600 }]
97
+ ```
98
+
99
+ ### Supported condition types
100
+
101
+ | `type` | Example value | Generated query |
102
+ |-------------------------|-------------------|-----------------------------------|
103
+ | `min-width` | `768` | `(min-width: 768px)` |
104
+ | `max-width` | `1023` | `(max-width: 1023px)` |
105
+ | `min-height` | `600` | `(min-height: 600px)` |
106
+ | `max-height` | `900` | `(max-height: 900px)` |
107
+ | `orientation` | `'portrait'` | `(orientation: portrait)` |
108
+ | `aspect-ratio` | `'16/9'` | `(aspect-ratio: 16/9)` |
109
+ | `prefers-color-scheme` | `'dark'` | `(prefers-color-scheme: dark)` |
110
+ | `prefers-reduced-motion`| `'reduce'` | `(prefers-reduced-motion: reduce)`|
111
+ | `prefers-contrast` | `'more'` | `(prefers-contrast: more)` |
112
+ | `hover` | `'none'` | `(hover: none)` |
113
+ | `pointer` | `'coarse'` | `(pointer: coarse)` |
114
+ | `forced-colors` | `'active'` | `(forced-colors: active)` |
115
+ | `resolution` | `'2dppx'` | `(resolution: 2dppx)` |
116
+ | `display-mode` | `'standalone'` | `(display-mode: standalone)` |
117
+ | `raw` | `'print'` | `print` *(verbatim)* |
118
+ | … and more | | |
119
+
120
+ ---
121
+
122
+ ## Global singleton
123
+
124
+ The library exports a pre-configured singleton `responsiveState` initialized with the default `ResponsiveConfig` (mobile / tablet / desktop).
125
+
126
+ ```ts
127
+ import { responsiveState, setResponsiveConfig, getResponsiveMediaQueries } from 'responsive-media';
128
+
129
+ // Re-configure the singleton
130
+ setResponsiveConfig(
131
+ {
132
+ sm: [{ type: 'max-width', value: 767 }],
133
+ lg: [{ type: 'min-width', value: 1024 }],
134
+ },
135
+ {
136
+ order: ['sm', 'lg'], // for isAbove/isBelow/between
137
+ debounce: 50, // ms, throttle subscribe() listeners
138
+ }
139
+ );
140
+
141
+ // Read the current state snapshot
142
+ const { sm, lg } = responsiveState.getState();
143
+
144
+ // Direct proxy access (live, non-debounced)
145
+ console.log(responsiveState.proxy.sm);
146
+
147
+ // Get the generated CSS strings
148
+ const mq = getResponsiveMediaQueries();
149
+ // { sm: '(max-width: 767px)', lg: '(min-width: 1024px)' }
150
+ ```
151
+
152
+ ### Default config (`ResponsiveConfig`)
153
+
154
+ | Key | Range |
155
+ |-----------|--------------|
156
+ | `mobile` | ≤ 600px |
157
+ | `tablet` | 601 – 960px |
158
+ | `desktop` | ≥ 961px |
159
+
160
+ ---
161
+
162
+ ## createResponsiveState — isolated instances
163
+
164
+ Create independent instances — useful for per-request SSR, multiple independent contexts, or testing:
165
+
166
+ ```ts
167
+ import { createResponsiveState, TailwindPreset, TailwindOrder } from 'responsive-media';
168
+
169
+ const layoutState = createResponsiveState(TailwindPreset, {
170
+ order: [...TailwindOrder],
171
+ });
172
+
173
+ const themeState = createResponsiveState({
174
+ dark: [{ type: 'prefers-color-scheme', value: 'dark' }],
175
+ reducedMotion: [{ type: 'prefers-reduced-motion', value: 'reduce' }],
176
+ });
177
+
178
+ layoutState.subscribe((s) => console.log('layout:', s));
179
+ themeState.subscribe((s) => console.log('theme:', s));
180
+
181
+ // Cleanup when done (e.g. per-request SSR)
182
+ layoutState.destroy();
183
+ ```
184
+
185
+ ---
186
+
187
+ ## ContainerState — element container queries
188
+
189
+ `ContainerState` tracks an **element's dimensions** via `ResizeObserver` and evaluates breakpoint conditions in JavaScript — the same concept as CSS Container Queries, but in JS.
190
+
191
+ The API is identical to `ReactiveResponsiveState` — all subscription methods work the same way.
192
+
193
+ ```ts
194
+ import { createContainerState } from 'responsive-media/container';
195
+ // or: import { createContainerState } from 'responsive-media';
196
+
197
+ const card = document.querySelector('.card')!;
198
+
199
+ const cardState = createContainerState(card, {
200
+ compact: [{ type: 'max-width', value: 300 }],
201
+ normal: [{ type: 'min-width', value: 301 }, { type: 'max-width', value: 599 }],
202
+ wide: [{ type: 'min-width', value: 600 }],
203
+ }, {
204
+ order: ['compact', 'normal', 'wide'],
205
+ });
206
+
207
+ // Reactive class toggling
208
+ cardState.on('compact', (v) => card.classList.toggle('card--compact', v));
209
+
210
+ // Sync CSS custom properties: --card-compact: 1; --card-wide: 0; …
211
+ cardState.syncCSSVars({ prefix: '--card-' });
212
+
213
+ // Get @container-compatible query strings
214
+ const strings = cardState.getMediaQueries();
215
+ // { compact: '(max-width: 300px)', wide: '(min-width: 600px)' }
216
+
217
+ // Cleanup
218
+ cardState.destroy();
219
+ ```
220
+
221
+ ### Supported condition types for ContainerState
222
+
223
+ `max-width`, `min-width`, `max-height`, `min-height`, `orientation`, `aspect-ratio`
224
+
225
+ ---
226
+
227
+ ## Subscription API
228
+
229
+ All methods below are available on both `ReactiveResponsiveState` and `ContainerState`.
230
+
231
+ ### `subscribe(listener)` → unsubscribe
232
+
233
+ Fires immediately with the current state, then on every change. Affected by `debounce`.
234
+
235
+ ```ts
236
+ const stop = state.subscribe((s) => {
237
+ document.body.dataset.bp = Object.keys(s).filter(k => s[k]).join(' ');
238
+ });
239
+ stop(); // unsubscribe
240
+ ```
241
+
242
+ ### `on(key, callback)` → unsubscribe
243
+
244
+ Fires immediately with the current value for `key`, then on every change. **Never debounced.**
245
+
246
+ ```ts
247
+ const off = state.on('mobile', (matches) => {
248
+ header.classList.toggle('header--mobile', matches);
249
+ });
250
+ off();
251
+ ```
252
+
253
+ ### `onEnter(key, callback)` → unsubscribe
254
+
255
+ Fires only on `false → true` transitions. Skips the initial value. **Never debounced.**
256
+
257
+ ```ts
258
+ state.onEnter('mobile', () => initMobileMenu());
259
+ ```
260
+
261
+ ### `onLeave(key, callback)` → unsubscribe
262
+
263
+ Fires only on `true → false` transitions. Skips the initial value. **Never debounced.**
264
+
265
+ ```ts
266
+ state.onLeave('mobile', () => destroyMobileMenu());
267
+ ```
268
+
269
+ ### `once(key, callback)` → unsubscribe
270
+
271
+ Fires on the **next change** to `key`, then auto-unsubscribes. Does not fire for the current value. **Never debounced.**
272
+
273
+ ```ts
274
+ state.once('mobile', (matches) => {
275
+ console.log('mobile changed to:', matches);
276
+ });
277
+ ```
278
+
279
+ ### `onNextChange(callback)` → unsubscribe
280
+
281
+ Fires on the **next global state change**, then auto-unsubscribes. Affected by `debounce`.
282
+
283
+ ```ts
284
+ state.onNextChange((s) => console.log('first change:', s));
285
+ ```
286
+
287
+ ### `onBreakpointChange(callback)` → unsubscribe
288
+
289
+ Fires when the **active breakpoint** changes (i.e. `current` changes), providing `from` and `to`. Affected by `debounce`.
290
+
291
+ ```ts
292
+ state.onBreakpointChange((from, to) => {
293
+ console.log(`breakpoint: ${from} → ${to}`);
294
+ });
295
+ ```
296
+
297
+ ### `waitFor(key, expectedValue?)` → Promise
298
+
299
+ Returns a `Promise` that resolves when `key` reaches `expectedValue` (default `true`). Resolves immediately if already met. **Never debounced.**
300
+
301
+ ```ts
302
+ await state.waitFor('desktop');
303
+ initDesktopChart();
304
+
305
+ // Wait for mobile to become false
306
+ await state.waitFor('mobile', false);
307
+ ```
308
+
309
+ ---
310
+
311
+ ## Ordered breakpoint helpers
312
+
313
+ These helpers require a breakpoint `order` — either set via `setConfig` / `createResponsiveState` options, or derived from config key insertion order.
314
+
315
+ ### `state.current` — getter
316
+
317
+ Returns the first active breakpoint key in order, or `null`.
318
+
319
+ ```ts
320
+ if (state.current === 'mobile') showDrawer();
321
+ ```
322
+
323
+ ### `state.isAbove(key)` → boolean
324
+
325
+ `true` when the current breakpoint comes **after** `key` in the order.
326
+
327
+ ```ts
328
+ // order: ['xs', 'sm', 'md', 'lg', 'xl']
329
+ // current = 'lg'
330
+ state.isAbove('sm') // → true
331
+ state.isAbove('xl') // → false
332
+ ```
333
+
334
+ ### `state.isBelow(key)` → boolean
335
+
336
+ `true` when the current breakpoint comes **before** `key` in the order.
337
+
338
+ ```ts
339
+ state.isBelow('md') // → true (current = 'sm')
340
+ ```
341
+
342
+ ### `state.between(from, to)` → boolean
343
+
344
+ `true` when the current breakpoint is between `from` and `to` (inclusive).
345
+
346
+ ```ts
347
+ state.between('sm', 'lg') // → true (current = 'md')
348
+ ```
349
+
350
+ ---
351
+
352
+ ## Utilities
353
+
354
+ ### `syncCSSVars(options?)` → stop
355
+
356
+ 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.
357
+
358
+ ```ts
359
+ const stop = state.syncCSSVars({ element: document.body, prefix: '--bp-' });
360
+ // → --bp-mobile: 1; --bp-desktop: 0; …
361
+ stop(); // cleanup
362
+ ```
363
+
364
+ **Options:**
365
+
366
+ | Option | Default | Description |
367
+ |-----------|--------------------------|----------------------------------|
368
+ | `element` | `document.documentElement` | Target HTML element |
369
+ | `prefix` | `'--responsive-'` | CSS custom property name prefix |
370
+
371
+ ### `emitDOMEvents(target?, options?)` → stop
372
+
373
+ Dispatches DOM `CustomEvent`s on `target` whenever breakpoints change:
374
+
375
+ - `responsive:change` — fires on any state change; `event.detail` is the full state snapshot
376
+ - `responsive:mobile:enter` — fires when `mobile` becomes `true`
377
+ - `responsive:mobile:leave` — fires when `mobile` becomes `false`
378
+
379
+ ```ts
380
+ const stop = state.emitDOMEvents(document, { prefix: 'bp:' });
381
+
382
+ document.addEventListener('bp:change', (e) => console.log(e.detail));
383
+ document.addEventListener('bp:mobile:enter', () => initDrawer());
384
+ document.addEventListener('bp:desktop:leave', () => destroyDesktopChart());
385
+
386
+ stop();
387
+ ```
388
+
389
+ **Options:**
390
+
391
+ | Option | Default | Description |
392
+ |----------|-----------------|--------------------------|
393
+ | `prefix` | `'responsive:'` | Custom event name prefix |
394
+
395
+ ### `toSignal(key, factory)` → Signal
396
+
397
+ Binds a breakpoint key to a writable signal from any signals library. The signal is kept in sync via `on()`.
398
+
399
+ ```ts
400
+ // @preact/signals-core
401
+ import { signal } from '@preact/signals-core';
402
+ const isMobile = state.toSignal('mobile', signal);
403
+ isMobile.value; // reactive boolean
404
+
405
+ // Angular signal
406
+ import { signal } from '@angular/core';
407
+ const isMobile = state.toSignal('mobile', signal);
408
+
409
+ // Vue ref
410
+ import { ref } from 'vue';
411
+ const isMobile = state.toSignal('mobile', ref);
412
+ ```
413
+
414
+ ### `getMediaQueries()` → Record\<string, string\>
415
+
416
+ Returns the generated CSS media query strings for each breakpoint key.
417
+
418
+ ```ts
419
+ const mq = state.getMediaQueries();
420
+ // { mobile: '(max-width: 600px)', desktop: '(min-width: 961px)' }
421
+ ```
422
+
423
+ ### `getState<T>()` → T
424
+
425
+ Returns a stable snapshot of the current state. Same reference between changes — safe for React's `useSyncExternalStore`.
426
+
427
+ ### `getOrder()` → string[]
428
+
429
+ Returns the configured breakpoint order array (or empty array if not set).
430
+
431
+ ### `hydrate(initialState)` — SSR hydration
432
+
433
+ Sets initial state from a server-side snapshot to prevent layout shift. Only updates keys that exist in the current config.
434
+
435
+ ```ts
436
+ // On the server, serialize state and pass to the client:
437
+ state.hydrate({ mobile: false, tablet: false, desktop: true });
438
+ ```
439
+
440
+ ### `destroy()`
441
+
442
+ Removes all `matchMedia` / `ResizeObserver` listeners, clears all subscribers, and cancels any pending debounce timer.
443
+
444
+ ### `toMediaQueryString(conditions)` — standalone utility
445
+
446
+ Converts a `MediaQueryConfig` to a CSS media query string. Useful for CSS-in-JS or debugging.
447
+
448
+ ```ts
449
+ import { toMediaQueryString } from 'responsive-media';
450
+
451
+ toMediaQueryString([{ type: 'min-width', value: 768 }, { type: 'max-width', value: 1024 }])
452
+ // → "(min-width: 768px) and (max-width: 1024px)"
453
+
454
+ toMediaQueryString([[{ type: 'max-width', value: 600 }], [{ type: 'orientation', value: 'portrait' }]])
455
+ // → "(max-width: 600px), (orientation: portrait)"
456
+ ```
457
+
458
+ ### `match(state, map, fallback?)` — standalone utility
459
+
460
+ Returns the first value in `map` whose key is `true` in `state`. Priority follows `map` insertion order.
461
+
462
+ ```ts
463
+ import { match } from 'responsive-media';
464
+ import { responsiveState } from 'responsive-media';
465
+
466
+ const cols = match(responsiveState.proxy, { mobile: 1, tablet: 2, desktop: 4 });
467
+ const View = match(responsiveState.proxy, { mobile: MobileMenu, desktop: DesktopNav });
468
+ const label = match(responsiveState.proxy, { sm: 'Compact', lg: 'Full' }, 'Default');
469
+ ```
470
+
471
+ ### `subscribeMediaQuery(query, callback)` — standalone utility
472
+
473
+ Low-level reactive wrapper around a single raw CSS media query string. Framework-agnostic — the Vue and React adapters use this internally.
474
+
475
+ ```ts
476
+ import { subscribeMediaQuery } from 'responsive-media';
477
+
478
+ const off = subscribeMediaQuery('(prefers-color-scheme: dark)', (matches) => {
479
+ document.body.classList.toggle('dark', matches);
480
+ });
481
+ off(); // cleanup
482
+ ```
483
+
484
+ ---
485
+
486
+ ## Presets
487
+
488
+ Import from `responsive-media/presets` or from the main entry point.
489
+
490
+ ### `ResponsiveConfig` (default)
491
+
492
+ | Key | Range |
493
+ |-----------|--------------|
494
+ | `mobile` | ≤ 600px |
495
+ | `tablet` | 601 – 960px |
496
+ | `desktop` | ≥ 961px |
497
+
498
+ ### `TailwindPreset` + `TailwindOrder`
499
+
500
+ Mutually exclusive Tailwind CSS v3/v4 breakpoints:
501
+
502
+ | Key | Range |
503
+ |-------|----------------|
504
+ | `xs` | ≤ 639px |
505
+ | `sm` | 640 – 767px |
506
+ | `md` | 768 – 1023px |
507
+ | `lg` | 1024 – 1279px |
508
+ | `xl` | 1280 – 1535px |
509
+ | `2xl` | ≥ 1536px |
510
+
511
+ ```ts
512
+ import { createResponsiveState, TailwindPreset, TailwindOrder } from 'responsive-media';
513
+
514
+ const state = createResponsiveState(TailwindPreset, { order: [...TailwindOrder] });
515
+ ```
516
+
517
+ ### `BootstrapPreset` + `BootstrapOrder`
518
+
519
+ Mutually exclusive Bootstrap 5 breakpoints:
520
+
521
+ | Key | Range |
522
+ |-------|----------------|
523
+ | `xs` | ≤ 575px |
524
+ | `sm` | 576 – 767px |
525
+ | `md` | 768 – 991px |
526
+ | `lg` | 992 – 1199px |
527
+ | `xl` | 1200 – 1399px |
528
+ | `xxl` | ≥ 1400px |
529
+
530
+ ```ts
531
+ import { createResponsiveState, BootstrapPreset, BootstrapOrder } from 'responsive-media';
532
+
533
+ const state = createResponsiveState(BootstrapPreset, { order: [...BootstrapOrder] });
534
+ ```
535
+
536
+ ### `AccessibilityPreset`
537
+
538
+ User-preference media queries. Multiple keys can be `true` simultaneously.
539
+
540
+ | Key | Matches when … |
541
+ |-----------------|-----------------------------------------|
542
+ | `dark` | `prefers-color-scheme: dark` |
543
+ | `light` | `prefers-color-scheme: light` |
544
+ | `reducedMotion` | `prefers-reduced-motion: reduce` |
545
+ | `highContrast` | `prefers-contrast: more` |
546
+ | `lowContrast` | `prefers-contrast: less` |
547
+ | `noHover` | `hover: none` (touch / stylus devices) |
548
+ | `coarsePointer` | `pointer: coarse` (finger-sized input) |
549
+ | `forcedColors` | `forced-colors: active` (Windows HCM) |
550
+ | `print` | `print` media type |
551
+
552
+ ```ts
553
+ import { createResponsiveState, AccessibilityPreset } from 'responsive-media';
554
+
555
+ const a11y = createResponsiveState(AccessibilityPreset);
556
+
557
+ a11y.onEnter('dark', () => applyDarkTheme());
558
+ a11y.onEnter('reducedMotion', () => disableAnimations());
559
+ a11y.onEnter('print', () => hideNonPrintable());
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Vue 3 integration
565
+
566
+ Import from `responsive-media` (main entry) or `responsive-media` directly — Vue composables are included in the main bundle.
567
+
568
+ ### Plugin registration
569
+
570
+ ```ts
571
+ import { createApp } from 'vue';
572
+ import { ResponsivePlugin } from 'responsive-media';
573
+
574
+ const app = createApp(App);
575
+
576
+ app.use(ResponsivePlugin, {
577
+ sm: [{ type: 'max-width', value: 767 }],
578
+ lg: [{ type: 'min-width', value: 1024 }],
579
+ });
580
+
581
+ app.mount('#app');
582
+ ```
583
+
584
+ ### `useResponsive<T>()` — reactive state object
585
+
586
+ Returns the Vue reactive responsive state. Reactive in templates and computed properties.
587
+
588
+ ```vue
589
+ <script setup lang="ts">
590
+ import { useResponsive } from 'responsive-media';
591
+
592
+ type MyState = { sm: boolean; lg: boolean };
593
+ const state = useResponsive<MyState>();
594
+ </script>
595
+
596
+ <template>
597
+ <MobileNav v-if="state.sm" />
598
+ <DesktopNav v-else />
599
+ </template>
600
+ ```
601
+
602
+ ### `useBreakpoints()` — ordered helpers
603
+
604
+ Returns reactive ordered breakpoint helpers. All methods react to viewport changes in templates.
605
+
606
+ ```vue
607
+ <script setup>
608
+ import { useBreakpoints } from 'responsive-media';
609
+
610
+ const { current, isAbove, isBelow, between } = useBreakpoints();
611
+ </script>
612
+
613
+ <template>
614
+ <span>Current: {{ current }}</span>
615
+ <DesktopNav v-if="isAbove('sm')" />
616
+ <MobileNav v-else />
617
+ <TabletOnly v-if="between('sm', 'lg')" />
618
+ </template>
619
+ ```
620
+
621
+ > `current` is a `ComputedRef<string | null>`. `isAbove`, `isBelow`, `between` are plain functions — reactive because they read from the Vue reactive state.
622
+
623
+ ### `useMediaQuery(query)` — single raw query
624
+
625
+ Returns a `Ref<boolean>` for a raw CSS media query string. Cleans up automatically on `onUnmounted`.
626
+
627
+ ```vue
628
+ <script setup>
629
+ import { useMediaQuery } from 'responsive-media';
630
+
631
+ const isDark = useMediaQuery('(prefers-color-scheme: dark)');
632
+ const canHover = useMediaQuery('(hover: hover)');
633
+ </script>
634
+
635
+ <template>
636
+ <DarkTheme v-if="isDark" />
637
+ </template>
638
+ ```
639
+
640
+ ### `useContainerState(elementRef, config, options?)` — container queries
641
+
642
+ Tracks an element's dimensions and returns a reactive state object. Sets up and tears down the `ResizeObserver` automatically via `watchEffect`.
643
+
644
+ ```vue
645
+ <script setup>
646
+ import { useTemplateRef } from 'vue';
647
+ import { useContainerState } from 'responsive-media';
648
+
649
+ const cardRef = useTemplateRef('card');
650
+ const cardState = useContainerState(cardRef, {
651
+ compact: [{ type: 'max-width', value: 300 }],
652
+ wide: [{ type: 'min-width', value: 600 }],
653
+ });
654
+ </script>
655
+
656
+ <template>
657
+ <div ref="card">
658
+ <CompactLayout v-if="cardState.compact" />
659
+ <WideLayout v-else-if="cardState.wide" />
660
+ <DefaultLayout v-else />
661
+ </div>
662
+ </template>
663
+ ```
664
+
665
+ ---
666
+
667
+ ## React 18+ integration
668
+
669
+ Import from `responsive-media/react`.
670
+
671
+ ```ts
672
+ import { useResponsive, useBreakpoints, useMediaQuery, useContainerState } from 'responsive-media/react';
673
+ ```
674
+
675
+ ### `useResponsive<T>()` — reactive state
676
+
677
+ Returns the current responsive state. Re-renders only when state changes. Uses `useSyncExternalStore` internally.
678
+
679
+ ```tsx
680
+ import { useResponsive } from 'responsive-media/react';
681
+
682
+ type MyState = { sm: boolean; lg: boolean };
683
+
684
+ function App() {
685
+ const { sm, lg } = useResponsive<MyState>();
686
+ return sm ? <MobileNav /> : <DesktopNav />;
687
+ }
688
+ ```
689
+
690
+ ### `useBreakpoints()` — ordered helpers
691
+
692
+ Returns ordered breakpoint helpers. Re-renders when the responsive state changes.
693
+
694
+ ```tsx
695
+ import { useBreakpoints } from 'responsive-media/react';
696
+
697
+ function Nav() {
698
+ const { current, isAbove, isBelow, between } = useBreakpoints();
699
+ return (
700
+ <>
701
+ <span>Current: {current}</span>
702
+ {isAbove('sm') ? <DesktopNav /> : <MobileNav />}
703
+ {between('sm', 'lg') && <TabletBanner />}
704
+ </>
705
+ );
706
+ }
707
+ ```
708
+
709
+ > Unlike Vue, `current` is a plain `string | null` (not a ref). Re-renders are triggered by `useSyncExternalStore`.
710
+
711
+ ### `useMediaQuery(query)` — single raw query
712
+
713
+ Returns a `boolean` that tracks a raw CSS media query string. SSR-safe (returns `false` on the server).
714
+
715
+ ```tsx
716
+ import { useMediaQuery } from 'responsive-media/react';
717
+
718
+ function ThemeToggle() {
719
+ const isDark = useMediaQuery('(prefers-color-scheme: dark)');
720
+ const canHover = useMediaQuery('(hover: hover)');
721
+ return <button className={isDark ? 'dark' : 'light'}>Toggle</button>;
722
+ }
723
+ ```
724
+
725
+ ### `useContainerState(ref, config, options?)` — container queries
726
+
727
+ Tracks an element's dimensions and returns a state object. Sets up and tears down `ResizeObserver` via `useEffect`.
728
+
729
+ ```tsx
730
+ import { useRef } from 'react';
731
+ import { useContainerState } from 'responsive-media/react';
732
+
733
+ function Card() {
734
+ const ref = useRef<HTMLDivElement>(null);
735
+ const { compact, wide } = useContainerState(ref, {
736
+ compact: [{ type: 'max-width', value: 300 }],
737
+ wide: [{ type: 'min-width', value: 600 }],
738
+ });
739
+
740
+ return (
741
+ <div ref={ref}>
742
+ {compact ? <CompactLayout /> : wide ? <WideLayout /> : <DefaultLayout />}
743
+ </div>
744
+ );
745
+ }
746
+ ```
747
+
748
+ > `config` and `options` are treated as static after mount. Wrap in `useMemo` if they change.
749
+
750
+ ---
751
+
752
+ ## TypeScript helpers
753
+
754
+ ### `ConfigToState<T>`
755
+
756
+ Derives a boolean-state type from a config object:
757
+
758
+ ```ts
759
+ import type { ConfigToState, MediaQueryConfig } from 'responsive-media';
760
+
761
+ const config = {
762
+ sm: [{ type: 'min-width', value: 640 }],
763
+ lg: [{ type: 'min-width', value: 1024 }],
764
+ } satisfies Record<string, MediaQueryConfig>;
765
+
766
+ type MyState = ConfigToState<typeof config>;
767
+ // → { sm: boolean; lg: boolean }
768
+
769
+ const { sm, lg } = responsiveState.getState<MyState>();
770
+ ```
771
+
772
+ ### Generic `useResponsive<T>()`
773
+
774
+ Both Vue and React adapters accept a generic type parameter to narrow the returned state:
775
+
776
+ ```ts
777
+ type AppState = { mobile: boolean; tablet: boolean; desktop: boolean };
778
+ const state = useResponsive<AppState>();
779
+ ```
780
+
781
+ ---
782
+
783
+ ## SSR / hydration
784
+
785
+ All APIs are SSR-safe — they check for `window` and `matchMedia` availability before use and fall back to `false` on the server.
786
+
787
+ For hydration (preventing layout shift):
788
+
789
+ ```ts
790
+ // Server: serialize the expected initial state
791
+ const initialState = { mobile: false, tablet: false, desktop: true };
792
+
793
+ // Client: hydrate before the first render
794
+ import { responsiveState } from 'responsive-media';
795
+ responsiveState.hydrate(initialState);
796
+ ```
797
+
798
+ ---
799
+
800
+ ## Exported API reference
801
+
802
+ ### Main entry (`responsive-media`)
803
+
804
+ | Export | Type | Description |
805
+ |---------------------------|--------------------------|-------------------------------------------------------|
806
+ | `responsiveState` | `ReactiveResponsiveState`| Global singleton, default `ResponsiveConfig` |
807
+ | `setResponsiveConfig` | function | Reconfigure the global singleton |
808
+ | `getResponsiveState` | function | Get state snapshot from global singleton |
809
+ | `getResponsiveMediaQueries` | function | Get CSS query strings from global singleton |
810
+ | `createResponsiveState` | function | Create an isolated `ReactiveResponsiveState` instance |
811
+ | `createContainerState` | function | Create a `ContainerState` for an element |
812
+ | `toMediaQueryString` | function | Convert `MediaQueryConfig` to CSS string |
813
+ | `match` | function | Pick a value by first matching breakpoint key |
814
+ | `subscribeMediaQuery` | function | Subscribe to a raw CSS media query string |
815
+ | `ResponsiveConfig` | const | Default mobile/tablet/desktop breakpoints |
816
+ | `BaseResponsiveState` | class | Abstract base (for extension) |
817
+ | `ReactiveResponsiveState` | class | Viewport state (matchMedia-backed) |
818
+ | `ContainerState` | class | Element container state (ResizeObserver-backed) |
819
+ | `ResponsivePlugin` | Vue plugin | Vue app plugin |
820
+ | `useResponsive` | Vue composable | Reactive state object |
821
+ | `useBreakpoints` | Vue composable | Ordered breakpoint helpers |
822
+ | `useMediaQuery` | Vue composable | Single raw media query |
823
+ | `useContainerState` | Vue composable | Element container queries |
824
+ | `ConfigToState` | type | Derives state type from config |
825
+ | `MediaQueryConfig` | type | Config entry type |
826
+ | `MediaQueryCondition` | type | Single condition type |
827
+ | `ResponsiveState` | type | `Record<string, boolean>` |
828
+ | `SetConfigOptions` | type | Options for `setConfig` / `createResponsiveState` |
829
+ | `BreakpointHelpers` | type | Return type of `useBreakpoints` |
830
+
831
+ ### React entry (`responsive-media/react`)
832
+
833
+ | Export | Description |
834
+ |---------------------|----------------------------------------------|
835
+ | `useResponsive` | State hook (useSyncExternalStore) |
836
+ | `useBreakpoints` | Ordered breakpoint helpers hook |
837
+ | `useMediaQuery` | Single raw media query hook |
838
+ | `useContainerState` | Element container queries hook |
839
+ | `BreakpointHelpers` | Type for `useBreakpoints` return value |
840
+
841
+ ### Presets entry (`responsive-media/presets`)
842
+
843
+ | Export | Description |
844
+ |----------------------|-----------------------------------------|
845
+ | `TailwindPreset` | Tailwind CSS v3/v4 breakpoints |
846
+ | `TailwindOrder` | Ordered key array for `TailwindPreset` |
847
+ | `BootstrapPreset` | Bootstrap 5 breakpoints |
848
+ | `BootstrapOrder` | Ordered key array for `BootstrapPreset` |
849
+ | `AccessibilityPreset`| User-preference media queries |
850
+
851
+ ### Container entry (`responsive-media/container`)
852
+
853
+ | Export | Description |
854
+ |-----------------------|----------------------------------------------|
855
+ | `ContainerState` | Class for element container queries |
856
+ | `createContainerState`| Factory function |
857
+
858
+ ---
859
+
860
+ ## License
861
+
862
+ MIT
863
+
864
+ ## Author
865
+
866
+ Danil Lisin aka Macrulez
867
+
868
+ GitHub: [macrulezru](https://github.com/macrulezru) · Website: [macrulez.ru](https://macrulez.ru/)