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

28.03.2026
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)

Установка

Copy
npm install responsive-media

Архитектура: базовый класс

Первое, что пришлось переосмыслить при добавлении container queries — это архитектура. В старой версии был один класс, который делал всё сразу: слушал matchMedia, хранил состояние, нотифицировал подписчиков. Когда понадобился ContainerState с ResizeObserver, стало очевидно: логика подписок, батчинг, дебаунс, упорядоченные брейкпоинты — всё это одинаково для обоих случаев. Дублировать её не хотелось.

Решение — абстрактный базовый класс BaseResponsiveState. Вся общая часть живёт в нём, а конкретные реализации переопределяют только setupSources и cleanupSources — то есть то, как именно получать данные об изменениях.

Copy
BaseResponsiveState
├── ReactiveResponsiveStatewindow.matchMedia
└── ContainerStateResizeObserver

Для пользователя это значит одно: API полностью одинаковый. Если вы научились работать с брейкпоинтами вьюпорта, то container queries не потребуют ничего нового.


Формат конфигурации

Каждый брейкпоинт — массив условий. Условия в одном массиве соединяются через AND, что соответствует поведению CSS:

ts Copy
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 Copy
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 Copy
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 Copy
const stop = responsiveState.subscribe((state) => {
  console.log('desktop:', state.desktop);
});
stop(); // отписка

on — подписка на один ключ

Когда интересует только один конкретный брейкпоинт. Тоже срабатывает сразу. Никогда не дебаунсится — важно для случаев, когда нужна немедленная реакция.

ts Copy
const off = responsiveState.on('mobile', (matches) => {
  header.classList.toggle('header--mobile', matches);
});
off();

onEnter / onLeave — переходы без лишнего шума

Разница с on принципиальная: эти методы не срабатывают при подписке. onEnter реагирует только на переход false → true, onLeave — на true → false. Это позволяет писать инициализацию и очистку естественно, без проверок внутри колбэка.

ts Copy
responsiveState.onEnter('mobile', () => {
  // Вызывается ровно один раз при каждом переходе в мобильный режим
  initMobileMenu();
});

responsiveState.onLeave('mobile', () => {
  // Вызывается ровно один раз при каждом выходе из мобильного режима
  destroyMobileMenu();
});

once — один раз при следующем изменении

Подписывается на следующее изменение конкретного ключа и сразу же отписывается. Текущее значение при этом игнорируется — только следующая смена. Полезно для одноразовых реакций без ручного управления подпиской.

ts Copy
responsiveState.once('mobile', (matches) => {
  console.log('mobile впервые изменился, теперь:', matches);
});

onNextChange — один раз при следующем глобальном изменении

Аналог once, но для всего состояния целиком. Удобно, например, для отложенной инициализации, которая должна произойти после первого изменения вьюпорта после загрузки страницы.

ts Copy
responsiveState.onNextChange((state) => {
  console.log('пользователь изменил размер окна впервые:', state);
});

onBreakpointChange — отслеживание смены брейкпоинта

Этот метод отвечает на вопрос «с какого на какой?». Это отличается от subscribe тем, что не срабатывает при изменениях, которые не меняют активный брейкпоинт (например, когда пользователь немного двигает окно внутри одного диапазона).

ts Copy
responsiveState.onBreakpointChange((from, to) => {
  console.log(`переход: ${from}${to}`);
  // Можно анимировать смену, логировать аналитику, etc.
});

waitFor — ждать конкретного состояния как Promise

Иногда инициализацию нужно отложить до определённого момента — и делать это через цепочку подписок неудобно. waitFor превращает ожидание в обычный await:

ts Copy
// Ждём, пока пользователь расширит окно до desktop
await responsiveState.waitFor('desktop');
initDesktopChart(); // выполнится ровно тогда, когда нужно

// Ждём выхода из мобильного
await responsiveState.waitFor('mobile', false);

Если условие уже выполнено на момент вызова — промис резолвится немедленно.


Упорядоченные брейкпоинты

Брейкпоинты часто образуют шкалу: xs → sm → md → lg → xl. Но по умолчанию библиотека об этом не знает — она просто хранит набор булевых значений. Чтобы включить «порядок», нужно его явно указать через опцию order:

ts Copy
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 Copy
console.log(responsiveState.current); // 'md'

isAbove / isBelow — сравнение с порогом

isAbove('sm') означает «мы сейчас правее sm на шкале», то есть md, lg, xl и так далее. Не нужно перечислять все варианты вручную:

ts Copy
// Текущий = 'lg', order = ['xs', 'sm', 'md', 'lg']
responsiveState.isAbove('sm');  // → true  (lg > sm)
responsiveState.isBelow('md');  // → false (lg не меньше md)

between — диапазон включительно

ts Copy
// Текущий = '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 Copy
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 Copy
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 Copy
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 Copy
import { createResponsiveState, BootstrapPreset, BootstrapOrder } from 'responsive-media';

const state = createResponsiveState(BootstrapPreset, { order: [...BootstrapOrder] });

Accessibility — пользовательские предпочтения

Это, пожалуй, самый интересный пресет. Он не про размеры экрана — он про то, что пользователь предпочитает: тёмную тему, сниженную анимацию, высокий контраст. Реагировать на эти предпочтения в JavaScript раньше требовало отдельных matchMedia на каждый случай. Теперь достаточно одного инстанса:

ts Copy
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 Copy
const stop = responsiveState.syncCSSVars({ prefix: '--bp-' });
// → --bp-mobile: 1; --bp-tablet: 0; --bp-desktop: 0;

Это открывает возможность управлять CSS-анимациями, переходами и даже вычисляемыми значениями чисто декларативно:

css Copy
/* Размер шрифта плавно пересчитывается через 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:entermobile стал true
  • responsive:mobile:leavemobile стал false
ts Copy
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 Copy
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 Copy
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 Copy
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 Copy
// На клиенте, до первого рендера:
import { responsiveState } from 'responsive-media';
responsiveState.hydrate(window.__INITIAL_STATE__.responsive);

Изолированные инстансы

Глобальный синглтон responsiveState удобен для большинства случаев, но не всегда. Например, в приложении может быть два независимых набора брейкпоинтов — один для лейаута, другой для пользовательских предпочтений. Или нужно создавать и уничтожать инстансы в рамках SSR-запроса, чтобы не было утечки между запросами.

Для этого есть createResponsiveState:

ts Copy
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 Copy
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 Copy
<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 Copy
<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 Copy
<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 Copy
<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 Copy
import { useResponsive } from 'responsive-media/react';

function App() {
  const { mobile, desktop } = useResponsive();
  return mobile ? <MobileLayout /> : <DesktopLayout />;
}

useBreakpoints — упорядоченные хелперы

В React current — это обычная строка (не ref), перерендер вызывается через useSyncExternalStore автоматически.

tsx Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
<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 Copy
responsiveState.syncCSSVars({ prefix: '--layout-' });
css Copy
: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 Copy
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 Copy
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 Copy
// На клиенте, до первого рендера:
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

Читать далее

28.03.2026

vue-i18n-kit — локализация для Vue 3 с ICU-плюрализацией, lazy loading и CLI

Написал собственный npm-пакет для локализации Vue 3, потому что устал каждый раз копировать один и тот же бойлерплейт из проекта в проект. ICU-плюрализация, lazy loading, метаданные локалей, форматирование дат и валют, Vite-плагин и CLI — всё из коробки.

Метки
vue3i18nlocalizationnpmopen-source
30.03.2026

color-value-tools 1.1.1: от конвертера форматов до полноценного инструментария для работы с цветом

color-value-tools вырос из простого конвертера цветовых форматов в полноценный инструментарий: CSS Color Level 4, перцептивная интерполяция, цветовые гармонии, симуляция дальтонизма, WCAG-доступность, генераторы и CLI — всё в одном пакете без зависимостей.

Метки
colortypescriptnpmwcagcss