javascript

Размытая граница между состояниями CSS и событиями JavaScript

  • вторник, 11 августа 2026 г. в 00:00:09
https://habr.com/ru/companies/timeweb/articles/1059272/

CSS «слушает» нас. Нет, не в буквальном смысле. Суть в том, что в CSS появляется все больше псевдоклассов, которые позволяют реагировать на события JS без необходимости писать сам JS. Формально псевдоклассы отслеживают состояния, а не события, но порой они настолько похожи на обработчики событий, что разница становится почти незаметной (хотя в контексте CSS это не так уж важно).

Впрочем, что вообще сегодня представляет собой CSS? Например, в спецификации Animation Triggers есть предложение добавить event-trigger — механизм, который сможет отслеживать события и запускать анимации. На мой взгляд, его синтаксис способен на гораздо большее (например, стать своеобразным аналогом команд invoker, но для CSS).

Но пока поговорим о том, что доступно уже сегодня. Сначала рассмотрим псевдоклассы CSS, которые в некотором смысле выполняют роль обработчиков событий, а затем перейдем к event-trigger и разберем, как, на мой взгляд, должна работать эта пока еще неподдерживаемая возможность.

❯ Псевдоклассы, регистрирующие события

:hover и :active

Состояние :hover действует в промежутке между событиями pointerenter и pointerleave. Это наглядно показывает, что псевдоклассы описывают состояния, а не события.

Псевдокласс :active соответствует элементу (например, ссылке или кнопке), который в данный момент удерживается нажатием мыши, пальцем или стилусом. В этом смысле он аналогичен событиям pointerdown и pointerup/pointercancel.

Стоит отметить, что CSS-свойство pointer-events: none предотвращает возникновение событий указателя (pointer events) на определенном элементе.

:focus и :focus-visible

Псевдокласс :focus можно сравнить с JS-событиями focus и blur (потеря фокуса), а вот с :focus-visible все немного сложнее.

:focus-visible возникает тогда же, когда и :focus, однако дополнительно браузер использует ряд эвристик, чтобы определить, нужно ли отображать индикатор фокуса. Пользователь взаимодействует с интерфейсом с помощью клавиатуры? Является ли элемент элементом формы? Именно такие возможности показывают, насколько далеко продвинулся CSS.

Более того, лучший способ воспроизвести такое поведение с помощью JS — просто обратиться к соответствующему CSS-псевдоклассу:

element.addEventListener("focus", (event) => {
  if (event.target.matches(":focus-visible")) {
    /* Выполнить какое-либо действие */
  }
});

:focus-within (и :has())

JS отлично справляется с задачами вида «если A находится в состоянии Y, то сделать Z с элементом B». Мы можем обходить DOM, использовать всплытие событий (event bubbling) и многое другое. На этом фоне возможности CSS могут показаться несколько ограниченными. Однако CSS развивается очень быстро. В нем появляется все больше возможностей, работающих по принципу «если произошло это — сделай то», например анимации, управляемые прокруткой (scroll-driven animations). В будущем таких механизмов станет еще больше. HTML тоже развивается в этом направлении, предлагая специализированные элементы, такие как <details>, которые сопровождаются соответствующими возможностями CSS.

К некоторым из них мы еще вернемся. А пока стоит упомянуть два псевдокласса: :focus-within и :has(). Псевдокласс :focus-within соответствует элементу, если фокус находится на одном из его дочерних элементов. Псевдокласс :has() принимает любой допустимый селектор и соответствует элементу, если между ним и указанным селектором существует определенная связь.

Например, оба следующих селектора делают одно и то же:

form:focus-within {
  /* Стилизуем форму, если фокус находится внутри нее */
}

form:has(:focus) {
  /* Стилизуем форму, если фокус находится внутри нее */
}

:checked

Назначение псевдокласса :checked вполне очевидно. Наиболее близким к нему JS-событием является change, которое возникает при изменении значения элемента <input>, <select> или <textarea> (хотя в данном случае событие input тоже во многом похоже).

Чтобы отследить изменение состояния чекбокса, можно написать примерно такой код:

checkbox.addEventListener("change", (event) => {
  if (event.target.checked) {
    /* Выбран */
  } else {
    /* Не выбран */
  }
});

Зачастую CSS-псевдоклассы описывают состояние, существующее между двумя JS-событиями (например, между pointerenter и pointerleave). А когда это не так, они обрабатывают логику, как в примере выше.

Давайте рассмотрим еще несколько примеров такой “скрытой” логики.

:valid, :invalid, :user-valid, :user-invalid и :autofill

В данном случае нам даже не нужен псевдокласс :not(), поскольку корректность данных можно проверить с помощью псевдоклассов :valid и :invalid.

В JS ситуация немного отличается: события valid не существует — есть только invalid. Тем не менее, при использовании JS обычно вызывают метод checkValidity(). Если он возвращает false, то автоматически генерируется событие invalid. Обычно checkValidity() вызывают внутри обработчиков событий input, change, blur (чтобы проверить корректность данных после потери фокуса элементом) или submit (чтобы проверить всю форму перед ее отправкой, как показано ниже).

form.addEventListener("submit", () => {
  if (form.checkValidity()) {
    //* Все элементы формы содержат корректные данные */
  } else {
    /* Один из элементов формы содержит некорректные данные (возникает событие `invalid`) */
  }
});

Того же результата можно добиться с помощью объекта ValidityState. В отличие от checkValidity(), он не генерирует событие invalid, но позволяет определить, почему элемент формы считается корректным или некорректным, используя ту же логику проверки, что и встроенная HTML-валидация.

input.addEventListener("input", () => {
  if (input.validity.valid) {
    /* Поле содержит корректные данные */
  } else {
    /* Поле содержит некорректные данные (событие `invalid` не возникает) */
  }
});

Преимущество встроенной HTML-валидации заключается в том, что она берет на себя всю проверку данных на стороне клиента. Но если требуется нестандартное поведение, следует использовать checkValidity() или объект ValidityState.

При этом CSS-псевдоклассы будут работать в любом случае. Более того, иногда даже слишком хорошо. Легко упустить из виду, что элементы формы получают состояние :valid или :invalid сразу после появления на странице. В отличие от них, :user-valid и :user-invalid срабатывают только после того, как пользователь введет значение и уберет фокус с элемента. Это поведение фактически соответствует событию change (за исключением чекбоксов, переключателей, раскрывающихся списков, инструментов выбора цвета и ползунков диапазонов), что и отличает его от события input.

Для автозаполнения формы в JS не существует отдельного события, да и надежного способа определить его тоже нет. Зато в CSS для этого предусмотрен псевдокласс :autofill.

❯ Псевдоклассы медиа элементов

Псевдоклассы для медиа элементов появились совсем недавно. Пока они не поддерживаются в Chrome и лишь недавно появились в Firefox, однако уже входят в программу Interop 2026. Это значит, что вскоре мы сможем стилизовать элементы <audio> и <video> в зависимости от их состояния, не прибегая к обработке JS-событий.

Думаю, к этому моменту принцип их работы уже понятен, поэтому вот краткое соответствие между CSS-псевдоклассами и JS-событиями:

Псевдокласс CSS

Событие JS

:buffering

waiting

:muted

volumechange (см. ниже)

:paused

pause

:playing

playing (не play)

:seeking

seeking

:stalled

stalled

:volume-locked

Нет аналога, см. ниже

Чтобы определить, отключен ли звук, можно использовать событие volumechange:

audio.addEventListener("volumechange", () => {
  if (audio.muted) {
    // Звук отключен
  } else {
    // Звук включен
  }
});

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

// Создаем элемент video
const video = document.createElement("video");

// Пытаемся изменить громкость
video.volume = 0.5;

if (video.volume !== 0.5) {
  // Изменение громкости заблокировано
} else {
  // Громкость можно изменять
}

В CSS для этого можно просто воспользоваться псевдоклассом :volume-locked.

:popover-open, :open и :modal

В JS нет отдельных событий, которые сообщают об открытии или закрытии всплывающих элементов popover, <dialog> или <details>. Вместо этого используется событие toggle, после которого проверяется текущее состояние элемента:

element.addEventListener("toggle", () => {
  if (element.open) {
    /* Popover, dialog или details открыт */
  } else {
    /* Popover, dialog или details закрыт */
  }
});

В CSS для этого предусмотрены соответствующие псевдоклассы:

  • :popover-open — для поповеров (popover);

  • :open — для элементов <dialog> и <details>;

  • :modal — для модальных <dialog> и элементов, отображаемых в полноэкранном режиме.

Кстати, что касается полноэкранного режима.

:fullscreen

Псевдокласс :fullscreen, по сути, соответствует JS-событию fullscreenchange, но со встроенной проверкой состояния:

document.addEventListener("fullscreenchange", () => {
  if (document.fullscreenElement) {
    /* Элемент fullscreenElement находится в полноэкранном режиме */
  } else {
    /* Полноэкранный режим не активен (fullscreenElement имеет значение `null`) */
  }
});

:target

Если фрагмент URL (например, #contact) совпадает со значением атрибута id элемента (например, <div id="contact">), этот элемент начинает соответствовать псевдоклассу :target. В JS для этого приходится отслеживать событие hashchange, а затем проверять, существует ли элемент с соответствующим идентификатором:

window.addEventListener("hashchange", () => {
  const target = document.getElementById(window.location.hash.substring(1));

  if (target) {
    /* Найден соответствующий элемент */
  } else {
    /* Соответствующий элемент не найден */
  }
});

❯ Заключение (но не совсем)

Эта статья не о том, что JS хуже CSS. Напротив, она показывает, насколько CSS способен упростить многие задачи, не лишая разработчиков гибкости и точного контроля, которые дает JS. Чем больше у нас способов решить одну и ту же задачу, тем лучше, так ведь?

И на этой позитивной ноте я хочу вернуться к event-trigger.

❯ Настоящие обработчики событий (event-trigger)

Я впервые обратил внимание на event-trigger, когда в Chrome появилась поддержка анимаций, запускаемых прокруткой (scroll-triggered animations), поскольку обе возможности относятся к одному и тому же модулю спецификации. Пока event-trigger не поддерживается ни одним браузером, поэтому, если я где-то ошибусь в деталях, заранее прошу прощения. Итак, начнем.

Свойство event-trigger-name принимает простой идентификатор с префиксом из двух дефисов:

button {
  event-trigger-name: --event;
}

Свойство event-trigger-source фактически определяет, какое событие будет отслеживаться.

Оно поддерживает следующие ключевые слова:

  • activate

  • interest

  • click

  • touch

  • dblclick

  • keypress(<string>)

button {
  event-trigger-source: click;
}

Насколько я понимаю, ключевое слово interest связано с будущим Interest Invoker API, а значение activate, вероятно, зависит от конкретного элемента. Например, для <details> активацией может считаться его открытие, но я не уверен. Надеюсь, последующие редакции спецификации прояснят этот момент и добавят новые типы событий.

В любом случае эти события предназначены для запуска анимаций. Сначала определяется анимация с помощью @keyframes, затем она привязывается к элементу, но, в отличие от обычного поведения, не запускается сразу, а ожидает возникновения соответствующего события.

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

div {
  animation: fade-in 300ms both;
}

Чтобы анимация запускалась при наступлении события, нужно использовать свойство animation-trigger вместе с animation, указав ранее заданный идентификатор (--event). Дополнительное преимущество такого подхода заключается в том, что событие одного элемента может запускать анимацию другого.

Ниже показан тот же пример, но с использованием сокращения event-trigger:

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

button {
  /* По клику запускаем событие --event */
  event-trigger: --event click;
}

div {
  /* Когда происходит --event, проигрываем анимацию вперед */
  animation-trigger: --event play-forwards;

  /* Анимация */
  animation: fade-in 300ms both;
}

Такой вариант называется триггером события без состояния (stateless event trigger). Логика проста: отменить уже произошедший клик невозможно. Но интерес пользователя может как появиться, так и исчезнуть. В этом случае можно использовать триггер события с состоянием(stateful event trigger). Обратите внимание на синтаксис: два события разделяются символом /, а для каждого состояния задается свое направление анимации:

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

button {
  /* interest (появление) / interest (исчезновение) */
  event-trigger: --event interest / interest;
}

div {
  /* Проигрываем анимацию вперед при появлении интереса и назад при его потере */
  animation-trigger: --event play-forwards play-backwards;

  /* Анимация */
  animation: fade-in 300ms both;
}

Для animation-trigger доступны следующие операции:

  • none

  • play

  • play-once

  • play-forwards

  • play-backwards

  • pause

  • reset

  • replay

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

При этом ничто не мешает запускать несколько разных анимаций одновременно, поскольку animation-trigger является сбрасываемым (reset-only) подсвойством свойства animation. Например:

animation-name: animationA, animationB;
animation-trigger: --eventA play, --eventB replay;

Потенциальные возможности этой технологии практически безграничны — многое будет зависеть от того, в каком направлении W3C продолжит развивать спецификацию (например, в ней уже упоминается возможность поддержки всплытия событий). Мне бы хотелось, чтобы триггеры событий позволяли вызывать методы JS — подобно тому, как это уже делает Invoker Commands API в HTML.

А как считаете вы? Это шаг в правильном направлении или CSS все же не стоит выходить за рамки своей основной задачи?


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале