responsive-media 1.1 — реактивные брейкпоинты, container queries и полный рефакторинг

В первых числах марте я уже писал про responsive-media — утилиту для создания реактивного состояния на основе CSS media queries. Там был базовый набор: синглтон с брейкпоинтами, подписка на изменения, плагин для Vue 3.
Примерно через неделю после публикации стало понятно, что остановиться на этом не получится. Начали накапливаться идеи: а что если нужна реактивность не по вьюпорту, а по размеру конкретного элемента? А что если хочется не просто узнать текущее состояние, но и среагировать именно на переход — когда пользователь вошёл в мобильный режим, а не просто находится в нём? А React?
В итоге библиотека прошла через несколько итераций, и то, что получилось — это уже совсем другой инструмент. Той же идеи, но значительно более зрелый. Разберём всё подробно.
Зачем вообще нужна реактивность на media queries?
Ответ кажется очевидным, пока не начинаешь объяснять его другому человеку. Попробую.
CSS прекрасно справляется с задачей показать или скрыть блок через @media. Но CSS — это про внешний вид, не про логику приложения. Как только появляется вопрос:
- загрузить другую версию данных для мобильного устройства (меньше строк, меньше полей)
- показать разное количество колонок в JS-сетке, которая рендерится программно
- включить или выключить анимацию в зависимости от
prefers-reduced-motion - пересобрать DOM-дерево целиком, а не просто скрыть элемент через
display: none
— CSS-решений уже не достаточно. Нужны реактивные булевы значения прямо в JavaScript, которые обновляются автоматически и не требуют от каждого компонента вручную слушать window.matchMedia.
Именно эту задачу и решает библиотека.
Что нового в версии 1.1
Прежде чем углубляться в детали, вот краткая карта изменений:
| Что добавлено | Зачем |
|---|---|
Абстрактный базовый класс BaseResponsiveState |
Общая логика для viewport и container queries |
ContainerState — container queries в JS |
Реактивность по размеру элемента, не вьюпорта |
| Полный subscription API | onEnter, onLeave, once, onNextChange, onBreakpointChange, waitFor |
| Упорядоченные брейкпоинты | current, isAbove, isBelow, between |
| Пресеты | Tailwind CSS, Bootstrap 5, Accessibility |
| React 18+ адаптер | useResponsive, useBreakpoints, useMediaQuery, useContainerState |
useBreakpoints для Vue |
Реактивные хелперы в шаблонах |
useContainerState для Vue |
Container queries в компонентах |
syncCSSVars |
Синхронизация в CSS custom properties |
emitDOMEvents |
DOM CustomEvent на каждый переход |
toSignal |
Интеграция с любой signals-библиотекой |
hydrate |
SSR hydration без layout shift |
match |
Маппинг состояния на значения |
| OR-логика в конфигурации | Вложенный массив = условия через запятую |
Тип raw |
Вставка медиа-типов verbatim (print, screen) |
Установка
npm install responsive-media
Архитектура: базовый класс
Первое, что пришлось переосмыслить при добавлении container queries — это архитектура. В старой версии был один класс, который делал всё сразу: слушал matchMedia, хранил состояние, нотифицировал подписчиков. Когда понадобился ContainerState с ResizeObserver, стало очевидно: логика подписок, батчинг, дебаунс, упорядоченные брейкпоинты — всё это одинаково для обоих случаев. Дублировать её не хотелось.
Решение — абстрактный базовый класс BaseResponsiveState. Вся общая часть живёт в нём, а конкретные реализации переопределяют только setupSources и cleanupSources — то есть то, как именно получать данные об изменениях.
BaseResponsiveState
├── ReactiveResponsiveState ← window.matchMedia
└── ContainerState ← ResizeObserver
Для пользователя это значит одно: API полностью одинаковый. Если вы научились работать с брейкпоинтами вьюпорта, то container queries не потребуют ничего нового.
Формат конфигурации
Каждый брейкпоинт — массив условий. Условия в одном массиве соединяются через AND, что соответствует поведению CSS:
ts
import { 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 }],
});
// → (max-width: 767px)
// → (min-width: 768px) and (max-width: 1023px)
// → (min-width: 1024px)
OR между группами условий
Иногда один брейкпоинт должен срабатывать при нескольких несвязанных условиях — например, «узкий экран ИЛИ портретная ориентация на планшете». Для этого условия объединяются через вложенный массив — каждый вложенный массив это отдельная группа, группы соединяются через запятую (OR в CSS-терминах):
ts
setResponsiveConfig({
narrow: [
[{ type: 'max-width', value: 600 }],
[{ type: 'orientation', value: 'portrait' }, { type: 'max-width', value: 1024 }],
],
});
// → (max-width: 600px), (orientation: portrait) and (max-width: 1024px)
Тип raw для медиа-типов
CSS media queries позволяют указывать медиа-тип словом без скобок: print, screen, all. Встроенные типы условий этого не поддерживали — пришлось добавить специальный тип raw, который вставляет значение в строку verbatim, не оборачивая скобками:
ts
setResponsiveConfig({
print: [{ type: 'raw', value: 'print' }],
screenNarrow: [{ type: 'raw', value: 'screen' }, { type: 'max-width', value: 600 }],
});
// → print
// → screen and (max-width: 600px)
Это особенно полезно в AccessibilityPreset для отслеживания печати — подробнее в разделе про пресеты.
Subscription API
Когда я думал о том, каким должен быть API подписок, ориентировался на один вопрос: что реально нужно в практических задачах? Оказалось, что кейсы сильно разные.
Иногда нужно просто знать текущее состояние и реагировать на каждое изменение — это subscribe и on. Иногда важен именно переход: не «мы на мобильном», а «мы только что стали мобильными» — это onEnter и onLeave. Бывает нужно подождать момента и сделать что-то один раз — это once и waitFor. А для аналитики или анимаций между экранами нужно знать и откуда, и куда — это onBreakpointChange.
Все методы доступны и на ReactiveResponsiveState, и на ContainerState.
subscribe — подписка на всё состояние
Базовый метод. Срабатывает сразу с текущим состоянием и затем при каждом изменении. Поддерживает дебаунс, если он настроен.
ts
const stop = responsiveState.subscribe((state) => {
console.log('desktop:', state.desktop);
});
stop(); // отписка
on — подписка на один ключ
Когда интересует только один конкретный брейкпоинт. Тоже срабатывает сразу. Никогда не дебаунсится — важно для случаев, когда нужна немедленная реакция.
ts
const off = responsiveState.on('mobile', (matches) => {
header.classList.toggle('header--mobile', matches);
});
off();
onEnter / onLeave — переходы без лишнего шума
Разница с on принципиальная: эти методы не срабатывают при подписке. onEnter реагирует только на переход false → true, onLeave — на true → false. Это позволяет писать инициализацию и очистку естественно, без проверок внутри колбэка.
ts
responsiveState.onEnter('mobile', () => {
// Вызывается ровно один раз при каждом переходе в мобильный режим
initMobileMenu();
});
responsiveState.onLeave('mobile', () => {
// Вызывается ровно один раз при каждом выходе из мобильного режима
destroyMobileMenu();
});
once — один раз при следующем изменении
Подписывается на следующее изменение конкретного ключа и сразу же отписывается. Текущее значение при этом игнорируется — только следующая смена. Полезно для одноразовых реакций без ручного управления подпиской.
ts
responsiveState.once('mobile', (matches) => {
console.log('mobile впервые изменился, теперь:', matches);
});
onNextChange — один раз при следующем глобальном изменении
Аналог once, но для всего состояния целиком. Удобно, например, для отложенной инициализации, которая должна произойти после первого изменения вьюпорта после загрузки страницы.
ts
responsiveState.onNextChange((state) => {
console.log('пользователь изменил размер окна впервые:', state);
});
onBreakpointChange — отслеживание смены брейкпоинта
Этот метод отвечает на вопрос «с какого на какой?». Это отличается от subscribe тем, что не срабатывает при изменениях, которые не меняют активный брейкпоинт (например, когда пользователь немного двигает окно внутри одного диапазона).
ts
responsiveState.onBreakpointChange((from, to) => {
console.log(`переход: ${from} → ${to}`);
// Можно анимировать смену, логировать аналитику, etc.
});
waitFor — ждать конкретного состояния как Promise
Иногда инициализацию нужно отложить до определённого момента — и делать это через цепочку подписок неудобно. waitFor превращает ожидание в обычный await:
ts
// Ждём, пока пользователь расширит окно до desktop
await responsiveState.waitFor('desktop');
initDesktopChart(); // выполнится ровно тогда, когда нужно
// Ждём выхода из мобильного
await responsiveState.waitFor('mobile', false);
Если условие уже выполнено на момент вызова — промис резолвится немедленно.
Упорядоченные брейкпоинты
Брейкпоинты часто образуют шкалу: xs → sm → md → lg → xl. Но по умолчанию библиотека об этом не знает — она просто хранит набор булевых значений. Чтобы включить «порядок», нужно его явно указать через опцию order:
ts
import { setResponsiveConfig } from 'responsive-media';
setResponsiveConfig(
{
xs: [{ type: 'max-width', value: 575 }],
sm: [{ type: 'min-width', value: 576 }, { type: 'max-width', value: 767 }],
md: [{ type: 'min-width', value: 768 }, { type: 'max-width', value: 991 }],
lg: [{ type: 'min-width', value: 992 }],
},
{ order: ['xs', 'sm', 'md', 'lg'] }
);
После этого становятся доступны три метода, которые делают код значительно выразительнее, чем набор if (state.lg || state.xl || state.xxl).
current — текущий активный брейкпоинт
Возвращает ключ первого активного брейкпоинта в порядке order, или null если ни один не активен.
ts
console.log(responsiveState.current); // 'md'
isAbove / isBelow — сравнение с порогом
isAbove('sm') означает «мы сейчас правее sm на шкале», то есть md, lg, xl и так далее. Не нужно перечислять все варианты вручную:
ts
// Текущий = 'lg', order = ['xs', 'sm', 'md', 'lg']
responsiveState.isAbove('sm'); // → true (lg > sm)
responsiveState.isBelow('md'); // → false (lg не меньше md)
between — диапазон включительно
ts
// Текущий = 'md'
responsiveState.between('sm', 'lg'); // → true (sm ≤ md ≤ lg)
responsiveState.between('lg', 'xl'); // → false (md < lg)
В шаблонах Vue и React-компонентах это читается как обычный человеческий текст, что важно для поддерживаемости.
ContainerState — container queries в JavaScript
CSS Container Queries появились в браузерах несколько лет назад и решают реальную проблему: стилизацию компонентов относительно их собственного размера, а не размера вьюпорта. Это здорово, но у CSS container queries есть ограничение — они про стили. Как только нужна логика в JavaScript, приходится изобретать своё решение.
ContainerState — это то же самое, что ReactiveResponsiveState, но источник данных не window.matchMedia, а ResizeObserver на конкретном элементе. Условия вычисляются в JavaScript по тем же правилам AND/OR, что и у media queries.
Важно: это не просто обёртка над ResizeObserver. Это полноценный объект со всем тем же subscription API — onEnter, onLeave, waitFor, syncCSSVars и так далее. Вы получаете единообразный интерфейс для двух принципиально разных источников изменений.
ts
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'],
});
// Реагируем на переходы
cardState.on('compact', (v) => card.classList.toggle('card--compact', v));
cardState.on('wide', (v) => card.classList.toggle('card--wide', v));
// Не забываем убирать за собой
cardState.destroy();
Бонус — getMediaQueries() возвращает строки, совместимые с CSS @container. Если вы захотите дублировать ту же логику в CSS, не придётся переписывать условия вручную:
ts
const strings = cardState.getMediaQueries();
// { compact: '(max-width: 300px)', wide: '(min-width: 600px)' }
Поддерживаемые условия для ContainerState: max-width, min-width, max-height, min-height, orientation, aspect-ratio.
Пресеты
Часто задаваемый вопрос при настройке: «а какие именно значения у Tailwind?». Теперь не нужно лезть в документацию — пресеты включены в поставку.
Tailwind CSS
ts
import { createResponsiveState, TailwindPreset, TailwindOrder } from 'responsive-media';
const state = createResponsiveState(TailwindPreset, {
order: [...TailwindOrder], // ['xs', 'sm', 'md', 'lg', 'xl', '2xl']
});
state.subscribe((s) => {
const cols = s['2xl'] ? 6 : s.xl ? 5 : s.lg ? 4 : s.md ? 3 : s.sm ? 2 : 1;
grid.setColumns(cols);
});
| Ключ | Диапазон |
|---|---|
xs |
≤ 639px |
sm |
640 – 767px |
md |
768 – 1023px |
lg |
1024 – 1279px |
xl |
1280 – 1535px |
2xl |
≥ 1536px |
Bootstrap 5
ts
import { createResponsiveState, BootstrapPreset, BootstrapOrder } from 'responsive-media';
const state = createResponsiveState(BootstrapPreset, { order: [...BootstrapOrder] });
Accessibility — пользовательские предпочтения
Это, пожалуй, самый интересный пресет. Он не про размеры экрана — он про то, что пользователь предпочитает: тёмную тему, сниженную анимацию, высокий контраст. Реагировать на эти предпочтения в JavaScript раньше требовало отдельных matchMedia на каждый случай. Теперь достаточно одного инстанса:
ts
import { createResponsiveState, AccessibilityPreset } from 'responsive-media';
const a11y = createResponsiveState(AccessibilityPreset);
a11y.onEnter('dark', () => applyDarkTheme());
a11y.onLeave('dark', () => applyLightTheme());
a11y.onEnter('reducedMotion', () => disableAnimations());
a11y.onEnter('print', () => hideSidebars());
| Ключ | Условие |
|---|---|
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 |
coarsePointer |
pointer: coarse |
forcedColors |
forced-colors: active |
print |
медиа-тип print |
Ключи в этом пресете независимы — несколько могут быть true одновременно. Например, dark и reducedMotion — совершенно нормальная комбинация.
Утилиты
syncCSSVars — брейкпоинты в CSS custom properties
Идея простая: если JS-состояние можно отразить в CSS-переменных, то CSS и JS можно синхронизировать без двойного описания логики. Каждый ключ превращается в переменную со значением 1 или 0:
ts
const stop = responsiveState.syncCSSVars({ prefix: '--bp-' });
// → --bp-mobile: 1; --bp-tablet: 0; --bp-desktop: 0;
Это открывает возможность управлять CSS-анимациями, переходами и даже вычисляемыми значениями чисто декларативно:
css
/* Размер шрифта плавно пересчитывается через calc */
:root {
font-size: calc(
(var(--bp-mobile) * 0.875 + var(--bp-tablet) * 1 + var(--bp-desktop) * 1.125) * 1rem
);
}
emitDOMEvents — DOM CustomEvent на каждый переход
Полезно в двух случаях: интеграция с легаси-кодом, который не знает о библиотеке, или Web Components, где прямой доступ к JS-объекту неудобен. На каждый переход dispatch'ится три типа событий:
responsive:change— любое изменение состояния,event.detailсодержит полный снапшотresponsive:mobile:enter—mobileсталtrueresponsive:mobile:leave—mobileсталfalse
ts
const stop = responsiveState.emitDOMEvents(document, { prefix: 'bp:' });
document.addEventListener('bp:change', (e) => console.log(e.detail));
document.addEventListener('bp:mobile:enter', () => initDrawer());
document.addEventListener('bp:desktop:leave', () => collapseTable());
toSignal — интеграция с signals-библиотеками
Signals сейчас активно распространяются: @preact/signals-core, Angular 17+, Solid.js, даже Vue ref() по интерфейсу совместим. Метод toSignal принимает фабричную функцию сигнала и возвращает уже подключённый сигнал — без необходимости самому управлять подпиской:
ts
import { signal } from '@preact/signals-core';
const isMobile = responsiveState.toSignal('mobile', signal);
const isDesktop = responsiveState.toSignal('desktop', signal);
// В любом контексте с поддержкой signals
effect(() => {
console.log('mobile:', isMobile.value);
});
Это делает библиотеку полностью фреймворко-независимой в части реактивности.
match — маппинг состояния на значения
Паттерн «если mobile — одно, если tablet — другое, если desktop — третье» встречается постоянно. match делает это одной строкой:
ts
import { match } from 'responsive-media';
const cols = match(responsiveState.proxy, { mobile: 1, tablet: 2, desktop: 4 });
const NavComponent = match(responsiveState.proxy, { mobile: MobileNav, desktop: DesktopNav });
const label = match(responsiveState.proxy, { sm: 'Compact', lg: 'Full' }, 'Default');
Приоритет — по порядку ключей в map. Если ни один не совпал — возвращается fallback (или undefined).
toMediaQueryString — конвертация конфига в строку
Иногда нужна строка для CSS-in-JS, тестов или дебага. Вместо того чтобы дублировать логику вручную, можно использовать ту же функцию, что использует библиотека внутри:
ts
import { toMediaQueryString } from 'responsive-media';
toMediaQueryString([{ type: 'min-width', value: 768 }, { type: 'max-width', value: 1024 }])
// → "(min-width: 768px) and (max-width: 1024px)"
hydrate — SSR hydration
При серверном рендеринге библиотека не знает реального состояния экрана — window недоступен. Это приводит к layout shift при гидратации: сервер отрендерил десктопную версию, браузер видит мобильный экран, компонент перерендеривается. hydrate решает это: вы передаёте серверный снапшот до первого рендера, и состояние устанавливается без мигания:
ts
// На клиенте, до первого рендера:
import { responsiveState } from 'responsive-media';
responsiveState.hydrate(window.__INITIAL_STATE__.responsive);
Изолированные инстансы
Глобальный синглтон responsiveState удобен для большинства случаев, но не всегда. Например, в приложении может быть два независимых набора брейкпоинтов — один для лейаута, другой для пользовательских предпочтений. Или нужно создавать и уничтожать инстансы в рамках SSR-запроса, чтобы не было утечки между запросами.
Для этого есть createResponsiveState:
ts
import { createResponsiveState, TailwindPreset, TailwindOrder, AccessibilityPreset } from 'responsive-media';
// Для лейаута — Tailwind-брейкпоинты
const layoutState = createResponsiveState(TailwindPreset, {
order: [...TailwindOrder],
});
// Отдельно — доступность и предпочтения
const a11yState = createResponsiveState(AccessibilityPreset);
// Полная очистка всех подписок и обработчиков
layoutState.destroy();
a11yState.destroy();
Оба инстанса — это ReactiveResponsiveState, они ничего не знают друг о друге.
Vue 3
Плагин
Плагин конфигурирует глобальный синглтон и делает реактивное состояние доступным через inject во всех компонентах приложения. Передавать стейт пропсами не нужно.
ts
import { createApp } from 'vue';
import { ResponsivePlugin } from 'responsive-media';
createApp(App)
.use(ResponsivePlugin, {
sm: [{ type: 'max-width', value: 767 }],
lg: [{ type: 'min-width', value: 1024 }],
})
.mount('#app');
useResponsive — реактивное состояние
Возвращает Vue-reactive объект. Используйте его напрямую в шаблоне или в computed — зависимости отслеживаются автоматически.
vue
<script setup lang="ts">
import { useResponsive } from 'responsive-media';
type Layout = { sm: boolean; lg: boolean };
const layout = useResponsive<Layout>();
</script>
<template>
<MobileNav v-if="layout.sm" />
<DesktopNav v-else />
</template>
useBreakpoints — упорядоченные хелперы в шаблоне
Это, пожалуй, самое удобное из того, что появилось в этой версии. Вместо v-if="layout.lg || layout.xl || layout['2xl']" пишете v-if="isAbove('md')" — читается как обычный текст.
Важная деталь реализации: isAbove, isBelow и between — это обычные функции, но они читают из Vue-реактивного объекта, поэтому Vue автоматически отслеживает их как зависимости в шаблоне.
vue
<script setup>
import { useBreakpoints } from 'responsive-media';
const { current, isAbove, isBelow, between } = useBreakpoints();
</script>
<template>
<p>Брейкпоинт: {{ current }}</p>
<DesktopNav v-if="isAbove('sm')" />
<MobileNav v-else />
<TabletBadge v-if="between('sm', 'lg')" />
</template>
current— этоComputedRef<string | null>.
useMediaQuery — один raw query
Для случаев, когда нужна реакция на произвольный media query вне системы брейкпоинтов. Возвращает Ref<boolean>, очищается автоматически при onUnmounted.
vue
<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 — container queries в компонентах
ResizeObserver поднимается и снимается автоматически через watchEffect — Vue сам следит за монтированием и размонтированием элемента.
vue
<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+
React-адаптер появился в этой версии. Основная сложность здесь — корректная интеграция внешнего хранилища с React. Использовать useState + useEffect недостаточно: это создаёт race condition и проблемы с concurrent mode. Правильный инструмент — useSyncExternalStore, появившийся в React 18. Именно он используется под капотом.
Импорт из responsive-media/react.
useResponsive — state hook
Рендер компонента происходит только при реальном изменении состояния — не при каждой нотификации.
tsx
import { useResponsive } from 'responsive-media/react';
function App() {
const { mobile, desktop } = useResponsive();
return mobile ? <MobileLayout /> : <DesktopLayout />;
}
useBreakpoints — упорядоченные хелперы
В React current — это обычная строка (не ref), перерендер вызывается через useSyncExternalStore автоматически.
tsx
import { useBreakpoints } from 'responsive-media/react';
function Nav() {
const { current, isAbove, between } = useBreakpoints();
return (
<>
<span>Активный: {current}</span>
{isAbove('sm') ? <DesktopNav /> : <MobileNav />}
{between('sm', 'lg') && <TabletBanner />}
</>
);
}
useMediaQuery — один raw query
SSR-safe: на сервере возвращает false, на клиенте подключается к реальному matchMedia.
tsx
import { useMediaQuery } from 'responsive-media/react';
function ThemeAware() {
const isDark = useMediaQuery('(prefers-color-scheme: dark)');
return <div className={isDark ? 'dark' : 'light'}>...</div>;
}
useContainerState — container queries в компонентах
ResizeObserver управляется через useEffect. config и options считаются статичными после монтирования — если они могут меняться, оберните их в useMemo.
tsx
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 ? <CompactCard /> : wide ? <WideCard /> : <DefaultCard />}
</div>
);
}
Реальные кейсы
1. Разная пагинация для мобильных
На мобильном экране таблица с 50 строками — мучение. Логичнее загружать меньше данных, а не просто скрывать лишние столбцы через CSS.
ts
import { responsiveState } from 'responsive-media';
responsiveState.subscribe((state) => {
const pageSize = state.desktop ? 50 : state.tablet ? 20 : 10;
table.setPageSize(pageSize);
});
2. Ленивая инициализация тяжёлого десктопного модуля
Графики, сложные drag-and-drop интерфейсы, редакторы — всё это имеет смысл загружать только тогда, когда они действительно нужны. onEnter идеально подходит для этого: код выполняется ровно в момент перехода, а onLeave — для очистки.
ts
responsiveState.onEnter('desktop', async () => {
const { initChart } = await import('./chart');
initChart(document.getElementById('chart')!);
});
responsiveState.onLeave('desktop', () => {
destroyChart();
});
3. Реакция на print через AccessibilityPreset
Задача: при печати убрать навигацию, показать скрытый контент, развернуть аккордеоны. CSS @media print со скрытием через display: none работает, но не для всего — некоторые изменения требуют манипуляций с DOM.
ts
import { createResponsiveState, AccessibilityPreset } from 'responsive-media';
const a11y = createResponsiveState(AccessibilityPreset);
a11y.onEnter('print', () => {
document.querySelectorAll('.no-print').forEach(el => (el as HTMLElement).hidden = true);
document.querySelectorAll('.accordion').forEach(el => el.classList.add('accordion--open'));
});
4. Карточка товара, которая умеет в три режима
Один и тот же компонент может быть узким в трёхколоночной сетке и широким при полноэкранном рендере. CSS container queries справляются с оформлением, но не с логикой — например, с тем, какие поля показывать в preview.
ts
class ProductCard {
private state = createContainerState(this.element, {
compact: [{ type: 'max-width', value: 240 }],
standard: [{ type: 'min-width', value: 241 }, { type: 'max-width', value: 400 }],
featured: [{ type: 'min-width', value: 401 }],
}, { order: ['compact', 'standard', 'featured'] });
constructor(private element: HTMLElement) {
this.state.subscribe((s) => this.render(s));
// CSS-переменные как бонус — для стилей не нужно дублировать условия
this.state.syncCSSVars({ element: this.element, prefix: '--card-' });
}
private render(s: Record<string, boolean>) {
const layout = s.featured ? 'horizontal' : s.compact ? 'mini' : 'default';
this.element.dataset.layout = layout;
}
destroy() { this.state.destroy(); }
}
5. Адаптивная таблица с переключением на карточки в Vue
Классический кейс: на десктопе — таблица с сортировкой и фильтрами, на мобильном — карточки. И параллельно — другой размер страницы, потому что на мобильном скроллить 50 строк неудобно.
vue
<script setup lang="ts">
import { computed } from 'vue';
import { useResponsive, useBreakpoints } from 'responsive-media';
const state = useResponsive();
const { isBelow, current } = useBreakpoints();
const pageSize = computed(() => isBelow('md') ? 10 : 50);
</script>
<template>
<TableView v-if="state.desktop || state.tablet" :page-size="pageSize" />
<CardList v-else :page-size="pageSize" />
<small>Текущий брейкпоинт: {{ current }}</small>
</template>
6. Связка JS-состояния с CSS через custom properties
Бывает нужно, чтобы CSS-анимации или переходы знали о текущем брейкпоинте — но прописывать логику и в JS, и в CSS не хочется. syncCSSVars автоматически поддерживает актуальность переменных:
ts
responsiveState.syncCSSVars({ prefix: '--layout-' });
css
:root {
--font-scale: calc(
var(--layout-mobile) * 0.875 +
var(--layout-tablet) * 1 +
var(--layout-desktop) * 1.125
);
font-size: calc(var(--font-scale) * 1rem);
}
TypeScript: строгая типизация
ConfigToState<T> — вывод типа из конфига
Чтобы не описывать типы отдельно от конфигурации, есть утилитарный тип ConfigToState. Он выводит тип состояния прямо из объекта конфига:
ts
import type { ConfigToState, MediaQueryConfig } from 'responsive-media';
const config = {
sm: [{ type: 'max-width' as const, value: 767 }],
lg: [{ type: 'min-width' as const, value: 1024 }],
} satisfies Record<string, MediaQueryConfig>;
type AppState = ConfigToState<typeof config>;
// → { sm: boolean; lg: boolean }
const { sm, lg } = responsiveState.getState<AppState>();
Дженерик useResponsive<T>()
Оба адаптера — Vue и React — принимают дженерик-параметр для сужения типа возвращаемого состояния:
ts
type LayoutState = { mobile: boolean; tablet: boolean; desktop: boolean };
const state = useResponsive<LayoutState>();
// state.mobile, state.tablet, state.desktop — типизированы, нет лишних ключей
SSR
Все API безопасны для серверного рендеринга. Перед использованием window, matchMedia и ResizeObserver каждый из них проверяет их доступность. На сервере всё возвращает false, не бросает ошибок.
Чтобы избежать layout shift при гидратации, передайте серверный снапшот клиенту. На сервере нужно посчитать состояние на основе User-Agent или другой эвристики, сериализовать его и встроить в HTML. На клиенте — передать в hydrate до первого рендера:
ts
// На клиенте, до первого рендера:
import { responsiveState } from 'responsive-media';
responsiveState.hydrate(window.__INITIAL_STATE__.responsive);
Итог
Когда я начинал эту библиотеку, задача казалась простой: «сделать реактивные брейкпоинты для Vue». Но чем глубже, тем яснее становилось, что задача шире — это управление адаптивным поведением приложения в целом, а не только его внешним видом.
Версия 1.1 — это попытка дать полноценный ответ на эту задачу:
- Viewport и Container — два источника данных, единый API
- Богатые подписки — для любого паттерна реакции
- Упорядоченные хелперы — для читаемых условий
- Готовые пресеты — для быстрого старта
- Vue 3 и React 18+ — с поддержкой container queries
- Утилиты — CSS vars, DOM events, signals, SSR
Библиотека написана на TypeScript, без рантайм-зависимостей, с полным покрытием тестами.
npm: responsive-media
GitHub: macrulezru/responsive-media