Псевдокласс в 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 с примерами на привязку к самому себе — в отдельной статье.
Родитель, который видит потомков: :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() */
article:has(section:has(img)) { }
/* и так нельзя — псевдоэлемент в аргументе */
li:has(::marker) { }
Последним :has() подтянул Firefox 121 в декабре 2023 года, а летом 2026-го селектор получил у Baseline статус Widely available — это значит, что с момента появления во всех движках прошло больше двух с половиной лет и фолбэк для него уже можно не держать.
Фокус: :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-настроек.
Третий псевдокласс этой группы — :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 до порядка сообщений об ошибках, собран в статье про логин и регистрацию.
Пустота и точный счёт: :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.
Частые ошибки
- Убрать outline и ничего не дать взамен. Самая дорогая ошибка из списка: страница становится непроходимой с клавиатуры. Рамку не убирают, а переносят на :focus-visible.
- Писать :read-only без имени тега. Селектор совпадёт со всем, что не редактируется, — то есть почти со всей страницей. Нужен input:read-only, textarea:read-only.
- Ждать от :empty реакции на пробел. Пробел и перенос строки делают элемент непустым. Чаще всего виноват не браузер, а форматирование шаблона.
- Забывать, что специфичность тянется из аргумента. Запись :is(#app, .page) p весит как селектор с идентификатором, и переписать её потом нечем. Если вес не нужен — :where().
- Красить форму на :invalid. Пустое обязательное поле невалидно с момента загрузки, и пользователь видит ошибку до того, как что-то сделал. Для реакции на ввод есть :user-invalid.
- Проверять поддержку селектора через проверку свойства. Правило @supports умеет спрашивать и про селекторы — для этого есть функция selector():
Подробнее про само правило — в статье про директиву @supports.@supports selector(:has(a)) { .card:has(img) { grid-template-columns: 120px 1fr; } }
Шпаргалка: задача и селектор
| Задача | Селектор |
| Сократить перечисление селекторов | :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.
Комментарии (0)