CSS незаметно накапливает псевдоклассы, которые обрабатывают взаимодействия, ранее требовавшие JavaScript. Обзор современной модели состояний CSS и её сравнение с JavaScript EventTarget API.
CSS слушает нас. Нет, не в буквальном смысле. Речь о том, что CSS накапливает всё больше псевдоклассов, которые позволяют реагировать на действия пользователя без единой строки JavaScript. Граница между тем, что CSS обрабатывает декларативно, и тем, что требует императивного JavaScript, смещается — и смещается уже давно.
Псевдоклассы отслеживают состояния, а не события. Это важное различие:
событие происходит в конкретный момент времени, а состояние существует на протяжении
некоторого периода. :hover охватывает весь промежуток между
pointerenter и pointerleave. Однако на практике
CSS-псевдокласс ощущается как обработчик события — и всё чаще CSS берёт на себя
логику, для которой раньше требовался JavaScript.
В этом руководстве я разберу каждый CSS-псевдокласс, который действует как обработчик
событий: классические состояния наведения/фокуса, реляционные селекторы
:focus-within и :has(), состояния валидации форм,
состояния медиаэлементов, а также экспериментальное предложение
event-trigger, которое дало бы CSS настоящие возможности прослушивания событий.
Самые известные CSS-состояния взаимодействия. :hover активируется, когда
курсор входит в область элемента, и деактивируется при выходе. Это соответствует
JavaScript-событиям pointerenter и pointerleave.
:active применяется, пока элемент нажат — между pointerdown
и pointerup/pointercancel. Оба — чистые трекеры состояний:
/* CSS — декларативное отслеживание состояния */
button:hover {
background: #e0f2fe;
transform: scale(1.02);
transition: transform 0.15s ease;
}
button:active {
transform: scale(0.98);
}
Сравните с JavaScript-аналогом:
// JavaScript — императивное отслеживание событий
button.addEventListener('pointerenter', () => {
button.style.background = '#e0f2fe';
button.style.transform = 'scale(1.02)';
});
button.addEventListener('pointerleave', () => {
button.style.background = '';
button.style.transform = '';
});
CSS-версия короче, декларативна и использует аппаратное ускорение композитинга. JS-версия даёт больше контроля — можно условно применять эффекты, запускать побочные действия или координировать несколько элементов — но для простых эффектов наведения CSS объективно лучше.
:focus применяется, когда элемент получает фокус (через клавиатуру,
клик или element.focus()). Это CSS-аналог JavaScript-событий
focus и blur.
:focus-visible интереснее. Браузер применяет его, когда :focus
срабатывает, но дополнительно использует эвристики для определения, нужно ли показать
видимый индикатор фокуса. Это способ браузера отличить навигацию с клавиатуры от кликов
мышью — различие, которое удивительно сложно реализовать в JavaScript:
/* CSS — автоматическое определение клавиатура/мышь */
button:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
button:focus:not(:focus-visible) {
outline: none; /* Клик мышью — скрываем кольцо */
}
Для воспроизведения этого в JavaScript пришлось бы запрашивать сам псевдокласс через
element.matches(':focus-visible'). CSS обрабатывает это нативно.
Здесь CSS действительно начинает вести себя как система событий. :focus-within
применяется к родительскому элементу, когда любой из его потомков в фокусе.
По сути, это всплытие события фокуса на уровне CSS.
:has() ещё мощнее — это реляционный селектор, который
срабатывает, если потомок соответствует заданному селектору. Эти два селектора делают
одно и то же:
/* Оба выделяют форму, когда дочерний input в фокусе */
form:focus-within {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.2);
}
form:has(:focus) {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.2);
}
Селектор :has() выходит далеко за рамки обработки фокуса — он может
обнаруживать любое состояние потомка, становясь настоящим условным оператором CSS.
Например, .card:has(.badge) изменяет раскладку карточки, если она содержит
значок, или form:has(:invalid) показывает баннер ошибки формы без JavaScript.
Псевдокласс :checked применяется к чекбоксам, радио-кнопкам и
элементам <option> в <select>, когда они
выбраны. JavaScript-аналог — событие change:
/* CSS */
.toggle:checked + .toggle-label {
background: #0f766e;
}
.toggle:checked ~ .menu {
display: block;
}
// JavaScript
checkbox.addEventListener('change', (event) => {
if (event.target.checked) {
menu.style.display = 'block';
} else {
menu.style.display = 'none';
}
});
Паттерн :checked + комбинаторы соседних элементов — основа
CSS-компонентов без JavaScript: выпадающих меню, аккордеонов,
вкладок и переключателей тёмной темы.
HTML-валидация форм имеет встроенное отслеживание состояний в CSS. Псевдоклассы
:valid и :invalid применяются на основе объекта
ValidityState —
той же валидации, которую браузер использует для нативного UI.
Однако :valid и :invalid срабатывают сразу
при загрузке страницы. Это означает, что пустое обязательное поле показывается как
:invalid до того, как пользователь к нему прикоснулся. Псевдоклассы
:user-valid и :user-invalid решают эту проблему — они
срабатывают только после реального взаимодействия пользователя с полем:
/* Показываем ошибки только после взаимодействия */
input:user-invalid {
border-color: #dc2626;
background: #fef2f2;
}
input:user-valid {
border-color: #16a34a;
background: #f0fdf4;
}
/* Подсветка автозаполненных значений */
input:autofill {
border-color: #16a34a;
}
Псевдокласс :autofill — ещё одна победа CSS. В JavaScript нет чистого
способа отследить автозаполнение поля браузером. :autofill обрабатывает
это нативно.
Для базовой стилизации валидации CSS не требует JavaScript вообще. Для нестандартного поведения (свои сообщения об ошибках, асинхронная валидация, проверки на сервере) JavaScript всё ещё необходим.
Одно из новейших дополнений CSS — и наглядная демонстрация того, как CSS приобретает
возможности, похожие на события — это набор псевдоклассов медиаэлементов
для <audio> и <video>.
Они входят в Interop 2026 и активно внедряются браузерами. Firefox уже добавил поддержку; Chrome работает над реализацией.
| CSS-псевдокласс | Аналог JavaScript-события | Описание |
|---|---|---|
:buffering |
waiting |
Медиа буферизирует данные |
:muted |
volumechange + .muted |
Звук отключён |
:paused |
pause |
Воспроизведение приостановлено |
:playing |
playing (не play) |
Активное воспроизведение |
:seeking |
seeking |
Пользователь перематывает |
:stalled |
stalled |
Данные неожиданно недоступны |
:volume-locked |
Прямого аналога нет | Регулировка громкости заблокирована ОС |
Пример: показываем индикатор загрузки только когда видео активно буферизируется:
video:buffering + .spinner {
display: block;
}
video:playing + .spinner {
display: none;
}
Псевдокласс :volume-locked особенно примечателен — в JavaScript нет
простого способа определить, заблокирована ли громкость на уровне ОС. Ближайший
JS-подход требует создания тестового элемента и попытки изменить его громкость —
это хрупко и имеет побочные эффекты. CSS обрабатывает это как нативный запрос состояния.
Нативные HTML-элементы вроде <dialog>, поповеров (атрибут
popover) и <details> имеют встроенные псевдоклассы
состояния, устраняющие необходимость в JavaScript для управления визуальным состоянием:
/* Стилизуем поповер в открытом состоянии */
[popover]:popover-open {
opacity: 1;
transform: scale(1);
}
/* Стилизуем открытый */
details:open summary {
font-weight: 600;
}
/* Стилизуем фон модального диалога */
dialog:modal::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
В JavaScript отслеживание этих состояний требует прослушивания события toggle
и проверки свойств open или modal:
// JavaScript-аналог
dialog.addEventListener('toggle', () => {
if (dialog.open) {
backdrop.style.opacity = '1';
} else {
backdrop.style.opacity = '0';
}
});
Псевдокласс :fullscreen применяется к элементу, отображаемому
в полноэкранном режиме. Его JavaScript-аналог — событие fullscreenchange
с проверкой document.fullscreenElement:
/* CSS */
#player:fullscreen {
background: #000;
padding: 0;
}
// JavaScript
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
player.style.background = '#000';
} else {
player.style.background = '';
}
});
Когда хеш URL (например, #section-3) совпадает с id элемента,
этот элемент соответствует псевдоклассу :target. Это позволяет реализовать
подсветку навигации и анимации появления секций без JavaScript:
section {
opacity: 0.3;
transition: opacity 0.4s ease;
}
section:target {
opacity: 1;
scroll-margin-top: 5rem;
}
С JavaScript пришлось бы слушать событие hashchange и вручную переключать
класс на целевом элементе. CSS делает это декларативно, с плавными переходами
на потоке композитинга браузера.
Спецификация Animation Triggers (CSS Level 5) предлагает
event-trigger — механизм, который дал бы CSS настоящие возможности
прослушивания событий. Пока не поддерживается ни одним браузером, но представляет
логическую конечную точку описанного в этой статье тренда.
Идея проста: элемент объявляет именованный триггер события, другой элемент подписывается на него, и когда событие срабатывает, запускается анимация. Предлагаемый синтаксис:
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Кнопка запускает --event по клику */
button {
event-trigger: --event click;
}
/* Div играет fade-in при срабатывании --event */
div {
animation-trigger: --event play-forwards;
animation: fade-in 300ms both;
}
event-trigger поддерживает stateless события (вроде
click — нельзя отменить клик) и stateful события (вроде interest —
интерес можно потерять). Для stateful событий используется синтаксис прямого/обратного
действия:
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
button {
event-trigger: --event interest / interest;
}
div {
animation-trigger: --event play-forwards play-backwards;
animation: fade-in 300ms both;
}
Ключевое слово interest относится к грядущему Interest Invoker API.
Другие источники событий: click, dblclick,
touch, activate и keypress().
Допустимые действия анимации: none, play,
play-once, play-forwards, play-backwards,
pause, reset и replay.
Если W3C продвинет эту спецификацию (а она упоминает возможность всплытия событий), мы можем увидеть мир, где клик по дочернему элементу запускает анимацию на совершенно не связанном родителе — всё в CSS, без JavaScript.
Со всеми этими новыми возможностями, когда стоит использовать CSS, а когда — JavaScript? Ответ не в черно-белых тонах — у обоих подходов есть своё место.
:user-valid):focus-within, :has())Хорошее практическое правило: если взаимодействие чисто визуальное — меняет внешний вид в ответ на изменение состояния — используйте CSS. Если взаимодействие должно что-то сделать (загрузить данные, записать в хранилище, скоординировать несколько компонентов) — используйте JavaScript.
CSS не пытается заменить JavaScript полностью. Он вытесняет те части JavaScript, которые никогда не были сильной стороной: простое управление состояниями UI, визуальную обратную связь и переключение классов в DOM. Для эволюции веба это здоровое разделение. CSS отвечает за внешний вид; JavaScript — за логику. И по мере того, как CSS получает всё больше возможностей для отслеживания состояний, граница продолжает смещаться — давая разработчикам больше выбора и более простой код.
Для более широкого взгляда на то, как CSS поглощает территорию JavaScript,
см. мою статью о современных CSS-паттернах,
заменяющих JavaScript, где рассматриваются CSS if(), style queries
и расширенный attr(). Также ознакомьтесь с
новыми CSS-возможностями Chrome 150
для последних реализаций в браузерах.
Создаёте веб-приложение с современным CSS? Я помогу спроектировать чистую, поддерживаемую архитектуру, использующую лучшее из CSS и JavaScript.