Мощные селекторы: :has(), :is(), :where()

Урок про три функциональных псевдокласса: «родительский» селектор :has(), а также :is() и :where(), которые сокращают длинные списки — но по-разному влияют на специфичность.

Функциональные псевдоклассы принимают список селекторов в скобках: :has() проверяет наличие потомка/состояния, а :is() и :where() сворачивают группы селекторов, различаясь только тем, как считается специфичность.

Зачем это на практике

Долгие годы CSS умел смотреть только «вниз и вправо» по дереву: нельзя было застилизовать родителя по содержимому. Длинные перечисления вроде h1, h2, h3, .title, .subtitle разрастались и дублировались. Эти три псевдокласса решают обе боли: :has() наконец даёт «восходящий» взгляд, а :is()/:where() делают селекторы короче и читаемее. Все три поддерживаются всеми актуальными браузерами.

:has() — «родительский» селектор

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

/* Карточка, у которой ВНУТРИ есть картинка */
.card:has(img) {
  padding-top: 0;
}

/* label, рядом с которым лежит обязательное поле */
label:has(+ input:required)::after {
  content: " *";
  color: #dc2626;
}

/* Форма, в которой есть невалидное поле */
form:has(input:invalid) .submit {
  opacity: 0.5;
}

Это открывает то, что раньше требовало JavaScript. Можно реагировать на состояние потомка: .gallery:has(:hover) приглушит остальные элементы, когда на один наведена мышь. Можно проверять количество: .list:has(li:nth-child(10)) сработает, когда в списке хотя бы 10 пунктов.

Селектор внутри :has() относительный — он рассматривается от элемента слева. div:has(> p) — это «div, у которого есть прямой потомок-абзац», а div:has(p) — «где-то внутри есть абзац».

:is() — короче длинные группы

:is(A, B, C) совпадает, если элемент подходит под любой из A, B, C. Главная польза — выносить общий контекст за скобки и не повторять его.

/* Было: дублируется .article перед каждым заголовком */
.article h1, .article h2, .article h3 { line-height: 1.2; }

/* Стало */
.article :is(h1, h2, h3) { line-height: 1.2; }

/* Особенно экономно при комбинаторном взрыве */
:is(header, main, footer) :is(a, button):hover {
  text-decoration: underline;
}

Последнее правило без :is() пришлось бы расписывать в шесть отдельных селекторов. Ещё одно удобство: :is() «прощает» неизвестные селекторы в списке — один неподдерживаемый пункт не обрушит остальное правило (forgiving parsing).

:where() — то же, но без специфичности

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

/* Специфичность этого правила = 0, как у тега */
:where(.btn, .link) { color: inherit; }

/* Любой более конкретный селектор легко победит */
.btn.primary { color: white; }   /* выиграет без !important */

Если бы вместо :where() стоял :is(), специфичность подскочила бы до уровня класса, и перекрыть базовое правило стало бы труднее.

Разная специфичность :is() и :where()

Это самый важный нюанс урока. У :where() специфичность всегда 0. У :is() специфичность равна самому «тяжёлому» селектору внутри скобок.

СелекторСпецифичность
:where(#id, .cls, p)0, 0, 0
:is(#id, .cls, p)1, 0, 0 (берётся от #id)
:is(.cls, p)0, 1, 0 (берётся от .cls)
:has(.cls)0, 1, 0 (как у самого тяжёлого внутри)

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

Упрощение длинных списков

Реальная экономия видна на типографике статьи: общий контекст пишется один раз.

/* Сброс отступов у всех заголовков и медиа внутри материала */
.prose :where(h2, h3, h4, p, ul, ol, blockquote, figure) {
  margin-block: 0.75em 0;
}

/* Состояния интерактивных элементов одним махом */
:is(a, button, [role="button"]):where(:hover, :focus-visible) {
  outline: 2px solid currentColor;
}

Запятая внутри :is()/:where() — это «или», а сами псевдоклассы можно сцеплять, собирая компактные, но точные правила.

Как это работает под капотом

Браузер раскрывает :is() и :where() так, будто вы написали все комбинации вручную: .a :is(b, c) логически эквивалентно .a b, .a c. Разница лишь в шаге подсчёта специфичности — у :where() вклад скобок принудительно обнуляется. :has() устроен сложнее: чтобы понять, подходит ли элемент, движку нужно заглянуть в его поддерево (или к соседям), поэтому исторически он и появился позже — это «дорогой» по вычислениям селектор. Современные браузеры оптимизируют его инвалидацию, но злоупотреблять очень широкими :has() на больших деревьях не стоит.

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

  • Думать, что :has() стилизует то, что в скобках. Оформляется элемент слева: в .card:has(img) меняется карточка, а не картинка.
  • Класть #id в :is(). Тогда специфичность всего правила прыгает до уровня id и неожиданно перекрывает другие стили. Для «нейтральности» берите :where().
  • Ждать нулевую специфичность от :is(). Ноль даёт только :where(); у :is() она равна самому тяжёлому пункту.
  • Забывать про относительность в :has(). :has(> p) (прямой потомок) и :has(p) (любой потомок) — это разные условия.

Итоги

  • :has() — долгожданный «родительский»/реляционный селектор: стилизует элемент слева по наличию потомка, соседа или их состояния.
  • :is(A, B, C) и :where(A, B, C) сворачивают группы селекторов и выносят общий контекст за скобки.
  • Ключевое различие: специфичность :where() всегда 0, а :is() равна самому тяжёлому селектору внутри.
  • Для базовых стилей и дизайн-систем берите :where(), чтобы их легко перекрывать.
  • Не кладите #id в :is(), если не хотите случайно поднять специфичность всего правила.
Проверьте себя
1. Что стилизует правило .card:has(img) { padding-top: 0; }?
AКартинку внутри карточки
BСаму карточку .card, при условии что внутри неё есть тег img
CВсе картинки на странице
DРодителя карточки
2. Чем :where() отличается от :is()?
A:where() выбирает только первый подходящий элемент
BНичем, это псевдонимы
CСпецифичность :where() всегда равна 0, а у :is() равна самому тяжёлому селектору в скобках
D:is() нельзя использовать с псевдоклассами
3. Какова специфичность селектора :is(#main, .box, p)?
A0,0,0 — как у where
B1,0,0 — берётся от #main, самого тяжёлого селектора в списке
C0,1,0 — берётся от .box
D0,0,3 — суммируется по трём селекторам