Псевдокласс в CSS — это уточнение к селектору через двоеточие, которое описывает не тип элемента, а его состояние или положение среди соседей. Запись button:hover выбирает не все кнопки на странице, а только ту, под которой прямо сейчас курсор:

button:hover {
  background-color: #2a6fdb;
}

Псевдокласс часто путают с псевдоэлементом, хотя разница простая. Псевдокласс пишется с одним двоеточием и выбирает элемент, который уже есть в разметке. Псевдоэлемент пишется с двумя (::before, ::first-line) и обращается к тому, чего в HTML нет: к сгенерированной коробке или к куску текста, у которого нет своего тега.

Долгое время арсенал был скромным: наведение, посещённая ссылка, номер по порядку. Сейчас в CSS есть псевдоклассы, которые умеют смотреть на потомков, отличать клик мышью от перехода по Tab и понимать, трогал ли пользователь поле формы или оно просто пустое с момента загрузки. Разберём их по задачам.

Группировка и исключения: :is(), :where() и :not()

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

/* было */
.entry h1,
.entry h2,
.entry h3 {
  font-family: 'Inter', sans-serif;
  letter-spacing: -0.01em;
}

/* стало */
.entry :is(h1, h2, h3) {
  font-family: 'Inter', sans-serif;
  letter-spacing: -0.01em;
}

Псевдокласс :is() принимает список селекторов и совпадает, если элемент подходит под любой из них. Экономия растёт нелинейно: если слева от группы стоит длинный префикс, при перечислении его нужно повторить столько раз, сколько вариантов в списке.

У :is() есть близнец — :where(). Он ведёт себя точно так же, кроме одного: его специфичность всегда равна нулю. Именно это делает его инструментом для базовых стилей, которые автор проекта должен уметь перебить одной строкой.

/* базовый слой: специфичность селектора = специфичность одного тега a */
:where(.prose) a {
  color: inherit;
  text-decoration-thickness: 1px;
}

/* правило проекта перебьёт его, хотя класса в селекторе нет */
a {
  color: #2a6fdb;
}

Если в первом правиле заменить :where на :is, картина перевернётся: селектор получит вес класса, и простым a его уже не переписать — придётся поднимать специфичность или лезть в чужой файл.

Вторая полезная особенность обоих — прощающий список селекторов. Если в обычном перечислении через запятую встретится хотя бы один селектор, который браузер не понимает, он отбросит правило целиком. Внутри :is() и :where() непонятный селектор просто игнорируется, а остальные продолжают работать.

Обратная задача — исключить элементы из выборки — решается через :not(). В четвёртом уровне спецификации селекторов он научился принимать список и сложные селекторы, так что цепочки вида :not(.a):not(.b) больше не нужны:

/* отступ у всех абзацев, кроме вводного и сноски */
p:not(.lead, .note) {
  margin-block-start: 1.2em;
}

/* разделитель между пунктами, но не после последнего */
.menu li:not(:last-child) {
  border-block-end: 1px solid #e3e3e3;
}

/* иконка внешней ссылки — кроме ссылок внутри карточек */
a[href^="http"]:not(.card > a) {
  padding-inline-end: 1.2em;
}

У трёх из четырёх функциональных псевдоклассов специфичность считается по самому весомому аргументу — про это легко забыть и потом полдня искать, почему правило не переписывается.

Селектор Специфичность Когда брать
:is() по самому весомому аргументу Сократить перечисление, когда вес селектора не мешает
:where() всегда нулевая Базовые стили и ресеты, которые должны легко перебиваться
:not() по самому весомому аргументу Исключить часть элементов из выборки
:has() по самому весомому аргументу Стилизовать элемент по тому, что у него внутри или рядом

Отсюда практический приём: если специфичность мешает, заворачиваем аргумент в :where() и обнуляем вес — :not(:where(#promo)) весит столько же, сколько и пустое место. Подробный разбор пары :is / :where с примерами на привязку к самому себе — в отдельной статье.

Поддержка браузерами
chrome
Chrome
88
firefox
Firefox
78
edge
Edge
88
safari
Safari
14
opera
Opera
75

Родитель, который видит потомков: :has()

Все привычные селекторы работают в одну сторону: от предка к потомку, от элемента к следующему соседу. Обратного хода не было — узнать в CSS, что внутри блока лежит картинка, и на этом основании перестроить сам блок было нельзя. Такие задачи годами уезжали в JavaScript: скрипт проверял содержимое и навешивал класс на родителя.

Псевдокласс :has() закрыл этот пробел. Он совпадает, если внутри элемента (или рядом с ним) находится то, что описано в аргументе:

/* карточка с картинкой получает двухколоночную сетку, без картинки — одну колонку */
.card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: 16px;
}

/* заголовок, за которым сразу идёт абзац, прижимается к нему */
h2:has(+ p) {
  margin-block-end: 0.3em;
}

/* блок с подсказкой появляется, только когда в форме есть ошибка */
form:has(:user-invalid) .form-hint {
  display: block;
}

/* меню, у которого есть вложенный список, ведёт себя иначе */
nav:has(> ul) {
  padding-inline: 0;
}

Аргумент здесь — относительный селектор: он отсчитывается от самого элемента, поэтому комбинаторы >, + и ~ можно ставить в самое начало. Из-за этого :has() не только «родительский селектор»: он умеет смотреть и на соседей, как в примере с заголовком выше.

Схема дерева документа: обычные селекторы выбирают вниз и вправо, а :has() позволяет выбрать предка

Два ограничения, о которых стоит знать заранее. Во-первых, :has() нельзя вложить в другой :has(). Во-вторых, псевдоэлементы недопустимы ни внутри аргумента, ни в роли элемента, к которому :has() привязан: многие псевдоэлементы сами зависят от стилей предков, и запрос по ним породил бы циклическую зависимость.

/* так нельзя — вложенный :has() */
article:has(section:has(img)) { }

/* и так нельзя — псевдоэлемент в аргументе */
li:has(::marker) { }

Последним :has() подтянул Firefox 121 в декабре 2023 года, а летом 2026-го селектор получил у Baseline статус Widely available — это значит, что с момента появления во всех движках прошло больше двух с половиной лет и фолбэк для него уже можно не держать.

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

Фокус: :focus-visible, :focus-within и :any-link

Рамка фокуса — больное место вёрстки. Дизайн её не любит, поэтому в проектах до сих пор встречается outline: none без всякой замены. Для человека, который ходит по странице клавиатурой, это означает потерю курсора: он жмёт Tab и не видит, где находится.

Псевдокласс :focus-visible снимает конфликт. Браузер сам решает по эвристике, нужна ли в этот раз видимая индикация: по кнопке щёлкнули мышью — не нужна, до неё дошли по Tab — нужна. Текстовые поля — особый случай: у них индикация показывается всегда, потому что пользователю важно понимать, куда попадёт ввод.

/* рамка появится при переходе по Tab, но не при клике мышью */
.btn:focus-visible {
  outline: 3px solid #2a6fdb;
  outline-offset: 2px;
}

В чужом коде встречается и более старая запись того же смысла — сначала рамку убирают у всех, потом возвращают тем, кому она нужна. Работает она так же, но читается хуже:

.btn:focus {
  outline: none;
}
.btn:focus:not(:focus-visible) {
  box-shadow: none;
}
.btn:focus-visible {
  outline: 3px solid #2a6fdb;
}

Соседний псевдокласс :focus-within решает обратную задачу: он совпадает у элемента, внутри которого есть что-то в фокусе. Удобно, когда подсветить надо не само поле, а весь его блок с подписью и подсказкой:

.field:focus-within {
  background-color: #f4f8ff;
  border-radius: 6px;
}

.field:focus-within label {
  color: #2a6fdb;
}

Больше приёмов вёрстки, которую не ломает клавиатура, — в статье про клавиатурную навигацию на чистом HTML и CSS, а тонкие настройки самих кнопок — в подборке мелких CSS-настроек.

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

Третий псевдокласс этой группы — :any-link. Он совпадает и с непосещёнными, и с посещёнными ссылками сразу, то есть заменяет пару :link, :visited. Важная деталь: он выбирает только те элементы, у которых действительно есть атрибут href. Пустая заготовка <a> без адреса под него не попадёт, а под селектор по тегу — попадёт.

:any-link {
  color: #2a6fdb;
  text-underline-offset: 2px;
}

Состояния полей формы

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

Плейсхолдер на месте. Псевдокласс :placeholder-shown совпадает, пока поле показывает текст плейсхолдера, то есть пока в него ничего не ввели. На этом построен приём с плавающей подписью: подпись изображает плейсхолдер, пока поле пустое, и уезжает наверх, когда пользователь начал печатать.

<div class="field">
  <input id="email" type="email" placeholder=" " />
  <label for="email">Электронная почта</label>
</div>
.field {
  position: relative;
}

.field input {
  padding: 18px 12px 6px;
}

.field label {
  position: absolute;
  inset-inline-start: 12px;
  top: 4px;
  font-size: 12px;
  color: #6b7280;
  transition: top 0.15s, font-size 0.15s;
}

/* поле пустое — подпись стоит там, где был бы плейсхолдер */
.field input:placeholder-shown + label {
  top: 14px;
  font-size: 16px;
}

Обратите внимание на placeholder=" " с пробелом: атрибут обязан быть, иначе псевдоклассу нечего показывать и правило не сработает никогда. При этом настоящую подпись плейсхолдер не заменяет — почему, разобрано в статье «Плейсхолдер — это не label».

Можно ли редактировать. Пара :read-only и :read-write различает заблокированные и редактируемые элементы. Здесь спрятана ловушка: :read-only по определению совпадает со всем, что не совпадает с :read-write, то есть равносилен записи :not(:read-write). Обычный абзац тоже нельзя редактировать — значит, он тоже :read-only. Селектор без имени тега покрасит весь документ, поэтому его всегда сужают:

/* так — только поля ввода */
input:read-only,
textarea:read-only {
  background-color: #f1f1f1;
  cursor: default;
}

/* :read-write ловит и contenteditable */
[contenteditable]:read-write:focus {
  outline: 2px dashed #2a6fdb;
}

Третье состояние. Псевдокласс :indeterminate описывает элементы, у которых значение не определено: чекбокс «выбрано частично», радиогруппу, в которой не отмечена ни одна кнопка, и <progress> без атрибута value.

input[type="checkbox"]:indeterminate + label {
  color: #a15c00;
  font-style: italic;
}

progress:indeterminate {
  opacity: 0.6;
}

Важный нюанс, который легко пропустить: у чекбокса это состояние в разметке не задаётся. HTML-атрибута indeterminate не существует, третье состояние выставляется только одноимённым свойством DOM-элемента:

const selectAll = document.querySelector('#select-all');

// отмечена часть задач, а не все и не ни одна
selectAll.indeterminate = true;

Кстати, у <progress> в CSS можно прочитать не только неопределённость, но и само значение заливки — разбор этого приёма есть в статье про тултип на нативном progress.

Ошибка, которую пользователь уже видел. Пара :user-valid и :user-invalid появилась позже остальных и решает главную проблему :invalid. Старый псевдокласс срабатывает сразу при загрузке: обязательное поле ещё никто не трогал, а оно уже красное. Новые совпадают только после того, как пользователь действительно взаимодействовал с полем.

input:user-invalid {
  border-color: #c0392b;
}

input:user-invalid + .field-error {
  display: block;
}

input:user-valid {
  border-color: #2e7d32;
}

Полный набор приёмов по разметке форм, от autocomplete до порядка сообщений об ошибках, собран в статье про логин и регистрацию.

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

Пустота и точный счёт: :empty и :nth-child(... of S)

Псевдокласс :empty совпадает с элементом, у которого нет ни одного потомка. Типовое применение — убрать контейнер, в который сервер ничего не положил, чтобы от него не осталось паддингов и рамки:

.notice:empty {
  display: none;
}

.badge:empty::after {
  content: '0';
}

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

<div class="notice"></div>                      <!-- :empty -->
<div class="notice"><!-- пусто --></div>        <!-- :empty -->
<div class="notice"> </div>                     <!-- не :empty: внутри пробел -->
<div class="notice">
</div>                                          <!-- не :empty: внутри перенос строки -->

Именно поэтому приём чаще ломается не в браузере, а в шаблонизаторе: он аккуратно расставляет отступы, и внутри «пустого» блока оказывается перенос строки. Вывод простой — если блока не должно быть, лучше не выводить его вообще, а не надеяться на :empty. В четвёртом уровне спецификации селекторов правило ослабили: элемент, внутри которого только пробелы, тоже должен считаться пустым, — но ни один браузер этого пока не реализовал, так что опираться на такое поведение рано.

Рядом стоит штука, которую в спецификации ждали давно: :nth-child() и :nth-last-child() научились принимать необязательную часть of S — список селекторов, по которому сначала отбираются дети, и только потом среди них идёт счёт.

/* зебра по видимым строкам: скрытые в счёте не участвуют */
tr:nth-child(2n of :not([hidden])) {
  background-color: #f7f7f7;
}

Разница с привычной записью принципиальная. Селектор tr:not([hidden]):nth-child(2n) сначала берёт каждую вторую строку таблицы, а потом отбрасывает из них скрытые — поэтому полоски в фильтрованной таблице разъезжаются. Вариант с of S считает по отфильтрованному набору и остаётся ровным. Про разницу между счётом по позиции и счётом по типу мы писали в статье про :nth-child и :nth-of-type.

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

Частые ошибки

  • Убрать outline и ничего не дать взамен. Самая дорогая ошибка из списка: страница становится непроходимой с клавиатуры. Рамку не убирают, а переносят на :focus-visible.
  • Писать :read-only без имени тега. Селектор совпадёт со всем, что не редактируется, — то есть почти со всей страницей. Нужен input:read-only, textarea:read-only.
  • Ждать от :empty реакции на пробел. Пробел и перенос строки делают элемент непустым. Чаще всего виноват не браузер, а форматирование шаблона.
  • Забывать, что специфичность тянется из аргумента. Запись :is(#app, .page) p весит как селектор с идентификатором, и переписать её потом нечем. Если вес не нужен — :where().
  • Красить форму на :invalid. Пустое обязательное поле невалидно с момента загрузки, и пользователь видит ошибку до того, как что-то сделал. Для реакции на ввод есть :user-invalid.
  • Проверять поддержку селектора через проверку свойства. Правило @supports умеет спрашивать и про селекторы — для этого есть функция selector():
    @supports selector(:has(a)) {
      .card:has(img) {
        grid-template-columns: 120px 1fr;
      }
    }
    Подробнее про само правило — в статье про директиву @supports.

Шпаргалка: задача и селектор

Задача Селектор
Сократить перечисление селекторов :is(...)
То же, но с нулевым весом — для базовых стилей :where(...)
Исключить часть элементов из выборки :not(...)
Стилизовать элемент по его содержимому или соседу :has(...)
Показать рамку фокуса только при навигации с клавиатуры :focus-visible
Подсветить блок, внутри которого что-то в фокусе :focus-within
Оформить ссылки независимо от посещённости :any-link
Поймать момент, когда поле ещё пустое :placeholder-shown
Отличить заблокированное поле от редактируемого :read-only, :read-write
Оформить «выбрано частично» и неопределённый прогресс :indeterminate
Показать ошибку только после ввода :user-valid, :user-invalid
Спрятать контейнер, в котором ничего нет :empty
Считать по позиции среди отобранных элементов :nth-child(... of S)

Итог

Современные псевдоклассы закрывают три разных задачи. Первая — сократить и обезопасить сами селекторы: :is(), :where() и :not() убирают перечисления и дают контроль над специфичностью. Вторая — прочитать состояние, которое раньше приходилось хранить в классе и переключать скриптом: фокус, пустое поле, неопределённый чекбокс, ошибка ввода. Третья — посмотреть на соседей и потомков, чем занимается :has().

Разбирать их удобнее не по алфавиту, а по задаче: сначала понять, какое состояние нужно поймать, и уже потом искать селектор в шпаргалке выше. Дальше можно копать в сторону тех, что только набирают поддержку, — :open для элементов с открытым и закрытым состоянием и :popover-open для всплывающих блоков: они продолжают ту же линию, в которой состояние интерфейса хранится в самой платформе, а не в JavaScript.