В любом проекте есть слой скриптов, которые ничего не вычисляют и никуда не ходят за данными. Они следят за интерфейсом: меряют ширину блока и вешают класс, ловят скролл и включают анимацию, открывают меню по клику, красят поле формы, когда рядом появилась ошибка. Такой код пишется на автопилоте — когда-то без него правда было никак.

Сейчас почти всё из этого списка закрывается стилями. Причём не «в теории и за флагом», а в стабильных версиях: контейнерные запросы и :has() дошли до статуса Baseline Widely Available, поповеры и переходы между состояниями работают во всех движках. Проблема не в платформе — проблема в привычке тянуться к скрипту раньше, чем к таблице стилей.

Разберём пять типовых кусков JS-склейки: что для каждого писали руками, чем это заменяется в CSS и где замена не работает. В конце — список того, что за скриптом остаётся навсегда.

Слой первый: компонент, который меряет себя сам

Классическая задача: карточка должна быть вертикальной в узкой колонке и горизонтальной в широкой. Медиа-запросы тут бессильны — они знают только про размер окна, а не про то, в какой слот эту карточку положили. Отсюда рождается наблюдатель:

const ro = new ResizeObserver(([entry]) => {
  const width = entry.contentRect.width;
  entry.target.classList.toggle('card--wide', width > 480);
});

document.querySelectorAll('.card').forEach((el) => ro.observe(el));

Дальше начинаются подробности, из которых состоит настоящая жизнь такого кода: наблюдатель нужно отключать при удалении элемента, при первом рендере класса ещё нет и карточка мигает, а компонент теперь знает про свои размеры и зависит от JS.

В CSS то же самое описывается объявлением контейнера и запросом к нему. Родитель объявляет, что его размеры можно опрашивать, а карточка формулирует условие сама:

.card-slot {
  container-type: inline-size;
  container-name: slot;
}

.card {
  display: grid;
  gap: 12px;
}

@container slot (width > 480px) {
  .card {
    grid-template-columns: 160px 1fr;
    align-items: center;
  }
}

Вместе с запросами появились и единицы, привязанные к контейнеру: cqi — процент от инлайн-размера, cqb — от блочного, есть также cqmin и cqmax. Заголовок, который тянется за шириной слота, а не за шириной окна, пишется одной строкой:

.card__title {
  font-size: clamp(1rem, 4cqi, 1.6rem);
}

Две вещи, о которые спотыкаются на старте. Первая: элемент не может опросить сам себя — container-type всегда живёт на родителе, иначе получилась бы циклическая зависимость. Вторая: container-type: inline-size включает containment по инлайн-оси, и блок перестаёт растягиваться под содержимое по этой оси — для контейнера-обёртки это нормально, для самого компонента чаще всего нет.

Поддержка браузерами
chrome
Chrome
106
firefox
Firefox
110
edge
Edge
106
safari
Safari
16.0
opera
Opera
94

Подробный разбор синтаксиса, вложенных контейнеров и разницы с медиа-запросами — в отдельном руководстве по container queries.

Слой второй: родитель, который видит детей

Второй по частоте скрипт-надсмотрщик занимается тем, что вешает класс на родителя, когда внутри что-то произошло. Поле формы стало невалидным — красим всю обёртку. Список опустел — показываем заглушку. Внутри блока появилась картинка — меняем раскладку.

const field = document.querySelector('.field');
const input = field.querySelector('input');

input.addEventListener('blur', () => {
  field.classList.toggle('field--invalid', !input.validity.valid);
});

Селектор :has() переворачивает направление каскада: теперь можно выбрать элемент по тому, что находится внутри него. Тот же кейс с формой:

.field:has(input:user-invalid) {
  --field-border: #c0392b;
}

.field:has(input:user-invalid) .field__hint {
  display: block;
}

.field:has(input:focus-visible) {
  outline: 2px solid #2d7ff9;
  outline-offset: 2px;
}

Псевдокласс :user-invalid здесь важен: в отличие от :invalid, он срабатывает только после того, как пользователь реально повзаимодействовал с полем. Пустая форма при загрузке не будет красной.

Дальше тем же приёмом закрывается всё, на что раньше вешали по обработчику:

/* строка таблицы, в которой отмечена галочка */
tr:has(input[type="checkbox"]:checked) { background: #fff8e1; }

/* выбранная карточка тарифа */
.plan:has(input:checked) {
  border-color: #2d7ff9;
  box-shadow: 0 0 0 3px rgba(45, 127, 249, 0.18);
}

/* кнопка оживает, только когда отмечено согласие */
.form:not(:has(#agree:checked)) .form__submit {
  opacity: 0.45;
  pointer-events: none;
}

/* пункт меню, внутри которого есть вложенный список */
.nav li:has(> ul) > a::after { content: ' \25be'; }

Последний пример показывает ещё одну сторону селектора: в скобках у него не обычный, а относительный селектор — такой же, как в querySelector после :scope. Комбинаторы там разрешены, поэтому :has(> ul) проверит только прямых потомков, а :has(+ .error) — следующего соседа.

Из нюансов. Специфичность :has() считается по самому «тяжёлому» аргументу внутри скобок, так что .card:has(#promo) внезапно перебивает почти всё в проекте. Вложить :has() в другой :has() нельзя. И стоит держать область поиска узкой: селектор от корня документа заставляет движок проверять условие на гораздо большем поддереве, чем локальный селектор внутри компонента.

Поддержка браузерами
chrome
Chrome
105
firefox
Firefox
121
edge
Edge
105
safari
Safari
15.4
opera
Opera
91

Рядом с :has() удобно держать псевдоклассы :is() и :where(): первый группирует условия, второй обнуляет специфичность, и вместе они спасают от селекторов-простыней.

Слой третий: состояние без единой переменной

Выпадающее меню, тултип, модальное окно, аккордеон — исторически самый жирный кусок склейки. Открыть, закрыть по клику вне, закрыть по Esc, увести фокус внутрь и вернуть обратно, не дать двум меню открыться одновременно. Обычно это несколько сотен строк или зависимость от библиотеки.

Атрибут popover отдаёт всё это браузеру:

<button popovertarget="user-menu">Профиль</button>

<div id="user-menu" popover>
  <a href="/settings">Настройки</a>
  <a href="/billing">Оплата</a>
  <button popovertarget="user-menu" popovertargetaction="hide">Закрыть</button>
</div>

Что мы получили бесплатно: элемент рисуется в верхнем слое (top layer), поэтому его не обрежет overflow: hidden у родителя и не перекроет чужой z-index; клик вне и Esc закрывают его сами; открытие второго поповера закрывает первый; состояние доступно в стилях через :popover-open, а подложка — через ::backdrop.

#user-menu {
  border: 1px solid #d9dde3;
  border-radius: 12px;
  opacity: 0;
  transition: opacity 160ms, display 160ms allow-discrete;
}

#user-menu:popover-open {
  display: grid;
  opacity: 1;
}

@starting-style {
  #user-menu:popover-open { opacity: 0; }
}

Обратите внимание, где стоит display — только внутри :popover-open. Это первое, на чём спотыкаются: закрытый поповер прячет браузерная таблица стилей правилом [popover]:not(:popover-open) { display: none }, а любое авторское объявление display в базовом правиле её перебивает — стили автора всегда сильнее браузерных, специфичность тут ни при чём. В итоге меню висит раскрытым, и кажется, что атрибут не работает.

Правило @starting-style задаёт значения, от которых начинается переход при появлении элемента — без него блок просто возникает в конечном состоянии. А allow-discrete в transition нужен для свойств, которые переключаются дискретно (как display): иначе анимация закрытия не проиграется, потому что элемент исчезнет мгновенно.

Вторая ловушка — позиция. Поповер живёт в верхнем слое и по умолчанию оказывается по центру экрана, а не под кнопкой: обычное position: absolute у родителя на него не влияет. Чтобы получилось выпадающее меню, нужна привязка к якорю — anchor-name на кнопке и функция anchor() в координатах меню:

.menu-btn { anchor-name: --menu-btn; }

#user-menu {
  position: absolute;
  position-anchor: --menu-btn;
  top: anchor(bottom);
  left: anchor(left);
  right: auto;
  bottom: auto;
  margin: 6px 0 0;
}

Якорное позиционирование — отдельная спецификация со своей историей поддержки, её стоит сверить на caniuse.com и на всякий случай обернуть в @supports (top: anchor(bottom)): без него меню просто останется по центру экрана, то есть выпадающее меню превратится в маленькое модальное окно.

Поддержка браузерами
chrome
Chrome
114
firefox
Firefox
125
edge
Edge
114
safari
Safari
17
opera
Opera
100

Для модальных окон появилась вторая пара атрибутов — command и commandfor. Кнопка объявляет, что она делает и с чем, и вызов showModal() из JS больше не нужен:

<button commandfor="confirm-dialog" command="show-modal">Удалить черновик</button>

<dialog id="confirm-dialog">
  <p>Черновик удалится без возможности восстановить.</p>
  <button commandfor="confirm-dialog" command="close">Отмена</button>
</dialog>

Аккордеон тоже давно не требует скрипта: пары details и summary достаточно, а общий атрибут name у нескольких блоков превращает их в группу, где открыт всегда один:

<details name="faq">
  <summary>Как отменить подписку</summary>
  <p>В разделе «Оплата» нажмите «Отменить продление».</p>
</details>

<details name="faq">
  <summary>Вернут ли деньги за неиспользованный период</summary>
  <p>Да, пропорционально остатку — в течение недели.</p>
</details>

Содержимому раскрытого блока можно задать отступы, фон и границы через псевдоэлемент ::details-content — раньше для этого внутрь приходилось заводить лишнюю обёртку.

Состояние в кастомном свойстве

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

.order { --status: pending; }
.order[data-status="paid"] { --status: paid; }
.order[data-status="canceled"] { --status: canceled; }

@container style(--status: paid) {
  .order__badge { background: #1e8449; color: #fff; }
}

@container style(--status: canceled) {
  .order__badge { background: #7f8c8d; color: #fff; }
  .order__pay-button { display: none; }
}

Тут есть приятная деталь: для запросов по кастомным свойствам не нужно объявлять container-type — любой элемент по умолчанию является style-контейнером. Значение наследуется вниз, поэтому одно объявление на корне карточки управляет видом всех её частей. Про сами кастомные свойства и их область видимости — в статье про CSS-переменные.

Поддержка браузерами
chrome
Chrome
111
firefox
Firefox
151
edge
Edge
111
safari
Safari
18.0
opera
Opera
98

Частичная поддержка в виджете означает ровно одно: запросы по кастомным свойствам работают, а запросы по обычным свойствам (условия вида style(display: grid)) пока нет. Для состояния компонента этого достаточно. В спецификации есть и функция if(), которая позволяет подставлять значение по условию прямо в объявлении, — за её поддержкой стоит следить на caniuse.com, в продакшен закладывать рано.

Слой четвёртый: скролл без слушателей

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

let ticking = false;

window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;

  requestAnimationFrame(() => {
    const max = document.documentElement.scrollHeight - innerHeight;
    bar.style.transform = `scaleX(${scrollY / max})`;
    ticking = false;
  });
});

Scroll-driven animations убирают отсюда весь код. Идея простая: обычная CSS-анимация получает вместо времени другой источник прогресса — положение скролла. Функция scroll() привязывает анимацию к прокрутке контейнера, view() — к тому, насколько элемент виден во вьюпорте.

@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.read-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  background: #2d7ff9;
  transform-origin: left center;
  animation: grow linear;
  animation-timeline: scroll(root block);
}

Обратите внимание: animation-duration не указан и не нужен — длительность задаёт таймлайн, а не секунды. Второй кейс, появление элемента при въезде в кадр, отличается только таймлайном и диапазоном:

@keyframes appear {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: none; }
}

.post-card {
  animation: appear linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 35%;
}

Поддержка браузерами
chrome
Chrome
115
firefox
Firefox
 
edge
Edge
115
safari
Safari
26.0
opera
Opera
101

Это единственная фича из статьи, где до полного покрытия ещё далеко, поэтому эффект пишем как надстройку: базовое состояние — видимое, а анимация включается только там, где таймлайны поддерживаются. Помогает директива @supports:

.post-card { opacity: 1; }

@supports (animation-timeline: view()) {
  .post-card {
    opacity: 0;
    animation: appear linear both;
    animation-timeline: view();
    animation-range: entry 10% cover 35%;
  }
}

Так в браузере без поддержки карточки просто окажутся на месте — вместо невидимого контента, который никогда не проявится. Заодно это дешевле любого JS-фолбека: анимации на таймлайне считаются вне основного потока, поэтому не дёргаются при загрузке страницы.

Слой пятый: переходы между состояниями и страницами

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

View Transitions делают снимок страницы до изменения и после, а затем анимируют разницу. Элементы, которые должны «переехать», помечаются именем:

.card__cover { view-transition-name: card-cover; }

/* группа отвечает за геометрию — позицию и размер */
::view-transition-group(card-cover) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* снимки до и после: плоской подложке кросс-фейд только вредит */
::view-transition-old(card-cover),
::view-transition-new(card-cover) {
  animation: none;
  height: 100%;
  width: 100%;
}

Здесь же прячется главная причина рваных переходов. Браузер строит на каждое имя три псевдоэлемента: ::view-transition-group() двигает и растягивает рамку, а ::view-transition-old() и ::view-transition-new() — это снимки содержимого до и после, которые по умолчанию перетекают друг в друга. Если задать длительность только снимкам, геометрия останется на дефолтных 250 мс, и элемент доедет до места раньше или позже, чем проявится картинка. Вторая причина — кросс-фейд корневого снимка поверх точечной анимации; когда конкретные элементы анимируются сами, его отключают:

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

Внутри одной страницы браузеру нужно сообщить, когда именно менять DOM. Это единственная строчка JS, которая тут остаётся:

document.startViewTransition(() => renderDetails(cardId));

Для перехода между документами не нужно и этого — достаточно правила на обеих страницах одного источника:

@view-transition {
  navigation: auto;
}

Поддержка межстраничных переходов отличается от однодокументных, её стоит сверять отдельно — на странице cross-document view transitions. И в любом случае эффект деградирует мягко: браузер без поддержки покажет обычную мгновенную смену содержимого.

Поддержка браузерами
chrome
Chrome
111
firefox
Firefox
144
edge
Edge
111
safari
Safari
18.0
opera
Opera
97

Что остаётся за скриптом

Тейк про «CSS вместо JavaScript» легко довести до абсурда, поэтому границу лучше обозначить сразу. Стили забирают слой реакции на интерфейсные события. За скриптом остаётся всё остальное:

  • данные — запросы к серверу, разбор ответов, кэширование, оптимистичные обновления;
  • состояние, которое должно переживать перезагрузку — CSS-состояние живёт только в дереве документа: его не сериализовать в URL, не положить в localStorage, не восстановить после навигации;
  • логика и вычисления — валидация с обращением к серверу, пересчёт корзины, сортировка и фильтрация таблиц;
  • сложная интерактивность — drag and drop, редакторы, графики, canvas и WebGL;
  • доступность за пределами дефолтовpopover и dialog закрывают базовые сценарии с фокусом и Esc, но объявления через aria-live, ловушки фокуса в нестандартных виджетах и порядок обхода по-прежнему требуют кода;
  • интеграции — аналитика, A/B-тесты, сторонние виджеты.

Плюс три подводных камня у самой замены.

  • Анимации нужно уважать настройки пользователя. Скролл-анимации и view transitions ощущаются как движение интерфейса, поэтому оборачиваются в медиа-запрос prefers-reduced-motion так же, как раньше оборачивались JS-анимации.
  • Отладка меняет характер. В скрипте можно поставить точку останова и посмотреть, кто позвал classList.add(). В стилях состояние размазано по каскаду, и вопрос «почему это включилось» решается панелью Styles в DevTools. Помогает договорённость держать входные точки состояния в одном месте: один data-атрибут или одно кастомное свойство на компонент, а не десять селекторов вразброс.
  • Верхний слой не дружит со старыми оверлеями. Поповеры и модальные окна рисуются в top layer, поэтому самодельная подложка с z-index: 9999 внезапно окажется под ними. При переходе на нативные поповеры старые оверлеи проще убрать, чем подружить.

Итог

Проверить, сколько склейки лежит в проекте, можно за один вечер. Ищем в коде пять паттернов:

  • ResizeObserver и класс-модификатор по ширине → container-type и @container;
  • обработчик, который вешает класс на родителя из-за содержимого → :has();
  • своя реализация меню, тултипа или модалки → popover, dialog с commandfor, details с общим name;
  • класс на каждый вариант состояния → кастомное свойство и @container style();
  • слушатель скролла или IntersectionObserver ради визуального эффекта → animation-timeline под @supports.

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