Откройте любой поживший фронтендовый компонент и посчитайте, сколько в нём JavaScript’а, который занимается не логикой, а внешним видом. Слушатель скролла, ужимающий хедер. ResizeObserver, перекрашивающий карточки при смене ширины родителя. Обработчик клика, вешающий класс на карточку с отмеченной радиокнопкой. Функция, которая меряет textarea и растягивает её под текст. Модальный менеджер: фокус-трап, ESC, блокировка скролла, война с z-index.
У всего этого есть ответ в современном CSS, и это не «ещё одна фича для любителей новинок». Ответ работает лучше не потому, что он короче, а потому что живёт в другом месте конвейера рендеринга. Дальше — механика: почему JS-версии этих вещей ломаются именно так, что можно удалить и где проходит граница, за которой JS по-прежнему обязателен.
Наблюдатель всегда опаздывает
Кадр в браузере собирается в фиксированном порядке: события → задача JS → пересчёт стилей → layout → отрисовка → композиция. JS-обработчик стоит в начале конвейера и смотрит на его результаты — геометрию, размеры, видимость — снаружи. Ему приходится читать то, что конвейер ещё не вычислил, и писать то, что обесценит вычисленное. Дальше — два дефекта, которые вы узнаете по симптомам.
Первый — принудительный синхронный layout. Обработчик скролла читает getBoundingClientRect или offsetHeight; если с прошлого кадра что-то менялось, браузер обязан пересчитать layout немедленно, прямо внутри обработчика. Затем обработчик пишет стили — и обесценивает собственные измерения. Читал, писал, читал: каждый цикл — оплаченный layout, который не попадает в текущий кадр, а ложится в следующий.
Второй — зазор наблюдателя. Событие приходит после того, как мир изменился: хедер реагирует на смещение прокрутки, которое уже в прошлом. Отсюда опоздание на кадр и подёргивание на быстрой прокрутке. Скролл — самый обидный случай: сам по себе он живёт на композиторе и не обязан занимать главный поток, а слушатель, который трогает layout, тащит работу обратно.
Отдельный подвид — matchMedia, попытка JS синхронизироваться с медиазапросами. Брейкпоинты оказываются записаны в двух местах, в CSS и в JS-константах, и дрейф между ними не ловит ни один линтер — каждый файл по отдельности корректен.
У CSS-ответов нет зазора, потому что нет наблюдателя. Контейнерные запросы вычисляются в том же проходе пересчёта стилей, в котором движок и так определяет размер контейнера: вопрос «карточка шире порога?» задаёт система, у которой ответ уже есть. Скролл-таймлайны — вообще не события: прогресс анимации — функция смещения прокрутки, движок сэмплирует её там, где рисует кадр, в идеале не трогая главный поток. Никто не читает снаружи и не догоняет задним числом.
Что именно переехало
Паттерн, который убирает :has(), знаком каждому: чекбокс меняется, обработчик ставит класс на родительскую карточку, стили реагируют на класс. Здесь два носителя одного состояния — свойство checked и класс-отражение. Каждая дорожка, по которой состояние может измениться — мышь, клавиатура, form.reset(), программная установка, ре-рендер фреймворка, — обязана помнить о синхронизации. Забыли одну — рассинхрон. С :has() отражение не нужно: стиль — чистая функция от DOM-состояния. Синхронизировать нечего, забывать нечего, гидрировать нечего.
.field:has(input:user-invalid) .hint { display: block; }
Контейнерные запросы убивают петлю ResizeObserver целиком: компонент спрашивает не о вьюпорте, а о собственном родителе, и честно ответить может только движок. JS-версии пришлось бы наблюдать за родителем вручную — со всеми гонками.
Скролл-реакции переезжают на скролл-таймлайны:
.site-header {
block-size: var(--header-full);
animation: shrink linear both;
animation-timeline: scroll(root);
}
@keyframes shrink {
to { block-size: var(--header-collapsed); }
}
Прогресс привязан к прокрутке документа, слушателей нет. Тот же механизм с view() вместо scroll() закрывает «появление при доскролле»: секции проявляются по мере входа в вьюпорт, IntersectionObserver не нужен. Оговорка: если хочется остаться на композиторе, анимируйте transform, а не высоту.
Ритуал появления из display: none держался долго, потому что display — дискретное свойство и через него переходы не идут. Код выглядел как цепочка синхронизаций: снять display: none, прочитать offsetHeight, чтобы форсировать пересчёт, поставить класс, дождаться transitionend, убрать класс. Каждое звено — место, где всё ломается: ресайз посреди анимации, изменение контента, гонка с другим эффектом. Теперь у словаря есть недостающее слово:
.toast {
opacity: 0;
transition: opacity .2s, display .2s allow-discrete;
}
.toast:popover-open {
opacity: 1;
@starting-style { opacity: 0; }
}
@starting-style описывает состояние «до первого кадра», allow-discrete говорит, в каком конце перехода переключать display, а сам display меняет механизм popover — без ручных классов.
Высота auto. Анимация «до auto» не работала, потому что auto — не число и интерполировать нечего. JS мерил scrollHeight, ставил пиксели, анимировал, на transitionend возвращал auto. interpolate-size и calc-size() позволяют интерполировать само ключевое слово: измерение переезжает внутрь layout, где оно всегда свежее.
Служебное, одной строкой: авторост textarea — field-sizing: content.
Модальности — popover и dialog. Самописный модал — война со страницей: z-index против чужих стекинг-контекстов, блокировка скролла через overflow, фокус-трап с ручной математикой Tab и Shift+Tab, восстановление фокуса, ESC. Атрибут закрывает всё это не потому, что «готовое удобнее», а потому что верхний слой, фокус и блокировка скролла принадлежат браузеру: страница не выиграет z-index у собственных стекинг-контекстов, сколько ни повышай. Фокус — не удобство, а доступность, а доступность — инженерная задача, а не галочка; у нас о ней есть отдельный материал.
| Задача | Было в JS | Стало в CSS |
|---|---|---|
| Стиль по состоянию потомка | слушатель + класс на родителе | :has() |
| Реакция на размер контейнера | ResizeObserver + инлайн-стили |
@container |
| Реакция на скролл | scroll + rAF |
animation-timeline: scroll() / view() |
Появление из display: none |
чтение offsetHeight, двойной rAF |
@starting-style + allow-discrete |
Анимация до auto |
scrollHeight + transitionend |
interpolate-size, calc-size() |
| Авторост textarea | измерение на каждый ввод | field-sizing: content |
| Модал, тултип | менеджер: фокус, ESC, скролл-лок | popover, dialog |
Мы уже писали, что размер бандла перестал быть главной метрикой. Здесь метрика ещё проще: код, которого нет, не надо грузить, парсить и гидрировать; он работает до загрузки JS и не падает вместе с ним. Это продолжение того же движения, о котором мы говорили в материале про серверные компоненты: меньше клиентского кода — больше работы, которую платформа делает сама.
Тупик: ускорить слушатель
Первое, что пробуют с дёргающимся скролл-обработчиком, — ускорить его: пассивный слушатель, троттлинг, debounce, батчинг в rAF, кэш измерений. Тупик объясняется по механизмам, а не по вкусу.
Пассивный слушатель снимает только налог «не скроллить, пока обработчик не отработал». Но дёрганье создавал не этот налог: обработчик всё так же читает layout и пишет стили, каждая итерация платит за пересчёт.
Debounce и троттлинг — осознанная сделка со свежестью: интерфейс какое-то время показывает заведомо неверное состояние. На ресайзе это видимая поломка вёрстки в окне ожидания, на скролле — перелёт хедера.
Батчинг в rAF выравнивает записи по кадру, но чтения всё равно форсируют синхронный layout, если с прошлой записи что-то менялось. В лучшем случае — то же опоздание на кадр, зато с лишней бухгалтерией синхронизации, которую теперь надо поддерживать.
Кэш измерений ломается первым же догрузившимся шрифтом, декодировавшейся картинкой или соседним компонентом, который что-то записал в DOM.
Общий знаменатель: стоимость цикла не в теле обработчика, а в его существовании. Наблюдатель снаружи, который патчит то, за чем наблюдает, настройками до нуля не выравнивается — можно только уменьшить дёрганье. Побочная ветка того же тупика — полифиллы: полифилл контейнерных запросов — это тот же ResizeObserver с шильдиком CSS, грузится и наблюдатель, и стили поверх него.
Работает не ускорение, а удаление: у движка ответ уже есть в том проходе, где он нужен.
Чего CSS не видит
Граница проходит по входу поведения. Вход — состояние рендера: какой размер, где прокрутка, что отмечено в DOM? Это CSS. Вход — смысл действия или данные снаружи DOM? Это JS: отправка формы, аналитика, многошаговые потоки с побочными эффектами, координация между окнами. Всё, что должно «делать», а не «выглядеть».
И честная оговорка про сам CSS: у него нет канала ошибок. Неизвестное свойство молча игнорируется — ни исключения, ни лога. Единственная обработка ошибок — @supports и деградация. Это одновременно сила: прогрессивное улучшение — родной режим CSS, а не надстройка. И дисциплина: фолбэк закладывают до того, как свойство не сработало.
Позиция редакции
Наша позиция: поведение, вход которого — состояние рендера, по умолчанию принадлежит CSS. Не потому, что так чище, а потому что владелец данных и их потребитель оказываются в одном проходе конвейера. Бремя доказательства — на стороне JS: сначала объясните, почему конвейер рендеринга не может владеть этим поведением, потом пишите хук.
Мы неправы, если матрица поддержки проекта перечисленного не включает и не включит: долгоживущие вебвью, закрытые окружения с зафиксированными браузерами. Тогда единственная честная архитектура — JS-слой наблюдателей поверх старого CSS, и «CSS по умолчанию» становится роскошью. Второе условие: если вход поведения в принципе не наблюдаем из DOM, спор не про CSS — он и не начинался.
А там, где движок гарантированно свежий — внутренние панели, оболочки, инструменты для себя, — позиция верна с запасом: каждый оставшийся слушатель скролла или ResizeObserver читается как баг архитектуры, пока не доказано обратное.