:has(), :is(), :where() — селекторы, которые всё изменили
Родительский селектор, о котором просили двадцать лет. Плюс два способа группировать селекторы и вложенность прямо в CSS.
Что ты научишься делать
- Научишься стилизовать родителя по содержимому — без единой строки JavaScript
- Перестанешь плодить длинные перечисления селекторов через запятую
- Поймёшь, чем :where() отличается от :is() и когда это важно
#Задача, которую раньше решали скриптом
Форма с ошибкой должна подсвечиваться целиком. Карточка с картинкой — верстаться в две колонки, без картинки — в одну. Меню, в котором есть активный пункт, — получать подчёркивание.
Во всех трёх случаях стиль зависит от содержимого. CSS умел смотреть только вниз: от родителя к детям. Поэтому такие вещи делали скриптом — навешивали класс на родителя.
:has() развернул направление. Теперь селектор может спросить: «а есть ли внутри меня вот это?»
/* Поле, внутри которого есть сообщение об ошибке */.field:has(.error) { border-color: #c8394a; background: #fff5f6;}/* Карточка с картинкой — в две колонки */.card:has(img) { grid-template-columns: 120px 1fr;}/* Меню, где есть активный пункт */.menu:has(.is-current) { border-bottom: 2px solid currentColor;}#Как читать :has()
Правило простое: стилизуется то, что слева от :has(), а внутри скобок стоит условие.
.card:has(img) — «карточка, внутри которой есть картинка». Красится карточка, не картинка.
Внутри скобок работает обычный селектор потомка. Комбинаторы тоже работают, и они меняют смысл.
| Селектор | Что означает |
|---|---|
li:has(p) | Пункт, внутри которого есть абзац на любой глубине |
li:has(> p) | Пункт, у которого абзац — прямой ребёнок |
h2:has(+ p) | Заголовок, сразу за которым идёт абзац |
.card:not(:has(img)) | Карточка, внутри которой картинки нет |
form:has(:invalid) | Форма, в которой есть хотя бы одно неверное поле |
#Счётные запросы
Приём, который выглядит как фокус: :has() умеет считать. Сетка может сама решать, сколько колонок ей нужно, по количеству карточек внутри.
Работает это так: :has(:nth-child(4)) — «внутри есть хотя бы четвёртый ребёнок», то есть детей не меньше четырёх.
/* Один-два снимка — крупно, в одну колонку */.gallery { grid-template-columns: 1fr; }/* Появился третий — переходим на три колонки */.gallery:has(:nth-child(3)) { grid-template-columns: repeat(3, 1fr);}/* Шесть и больше — мельчим и включаем перенос */.gallery:has(:nth-child(6)) { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));}#:is() — сокращение для длинных списков
Знакомая картина: одно и то же правило для шести селекторов, каждый со своим длинным началом.
Так не надо
.article h1,
.article h2,
.article h3,
.sidebar h1,
.sidebar h2,
.sidebar h3 {
margin-block: 1.2em .4em;
}Шесть строк ради одного правила. Добавится h4 — править в двух местах, и легко забыть одно.
Так надо
:is(.article, .sidebar) :is(h1, h2, h3) {
margin-block: 1.2em .4em;
}Одна строка. Добавить h4 — дописать одно слово. Читается почти как обычное предложение.
#:where() — то же самое, но с нулевым весом
:where() работает как :is(), но его специфичность всегда равна нулю. Совсем.
Это делает его идеальным для базовых стилей: правило применится, но перебить его сможет любой класс. Ровно то, что нужно от сброса и типографики.
/* Вес 0-0-0: любой компонентный класс перекроет без борьбы */:where(.prose) :where(h2, h3) { margin-block: 1.5em .5em;}/* И вот это спокойно побеждает, хотя селектор совсем простой */.card__title { margin: 0;}| :is() | :where() | |
|---|---|---|
| Что делает | Группирует селекторы | Группирует селекторы |
| Специфичность | Как у самого тяжёлого внутри | Всегда ноль |
| Где уместен | Компоненты, где вес нужен | Сброс, база, библиотечные стили |
#Вложенность прямо в CSS
Раньше за вложенностью шли в Sass. Теперь она есть в самом CSS: символ & означает «текущий селектор».
.card { padding: 20px; border-radius: 14px; & .card__title { margin: 0 0 8px; } /* & перед псевдоклассом — это тот же .card */ &:hover { box-shadow: 0 12px 30px -18px rgb(20 24 48 / .5); } /* Медиазапрос тоже можно вложить */ @media (min-width: 640px) { padding: 28px; }}#Как это выглядит в реальном проекте
Кусок стилей карточки товара из работающего каталога. Здесь используются все четыре приёма сразу — и это нормальный современный CSS, а не демонстрация.
@layer components { .product { display: grid; gap: 12px; padding: 16px; /* Есть бейдж скидки — сдвигаем заголовок, чтобы не наезжал */ &:has(.product__badge) .product__title { padding-inline-end: 64px; } /* Нет картинки — не резервируем под неё место */ &:not(:has(.product__media)) { grid-template-rows: auto; } /* Товара нет в наличии — гасим всё, кроме кнопки «сообщить» */ &:has(.product__stock[data-empty]) :is(.product__price, .product__media) { opacity: .45; } }}#Когда НЕ надо
- Заменять состояние скриптом наоборот. Если состояние всё равно приходит из JavaScript, честнее повесить класс, чем строить
:has()на десять условий: класс читается за секунду, а длинный:has()— нет. - Гнаться за «без JS» любой ценой. Аккордеон на
:has()и чекбоксах работает, но остаётся невидимым для программ чтения с экрана: у него нет ролей и состояний. Интерактив с семантикой — задача не для CSS. - Цеплять :has() к очень широким селекторам.
* :has(img)заставляет браузер проверять каждый элемент дерева. На большой странице это заметно. Ограничивай левую часть конкретным классом. - Вкладывать глубже двух уровней. Вложенность экономит символы и тут же тратит их обратно на борьбу со специфичностью.
#Поддержка браузерами
| Возможность | Состояние |
|---|---|
:is(), :where() | Во всех современных браузерах с 2021 года |
:has() | Safari первым, с 2022; Chrome и Firefox подтянулись к концу 2023 |
Вложенность с & | Во всех современных браузерах с 2023 года |
Где спотыкаются почти все
Ожидание, что покрасится то, что внутри скобок
Почему .card:has(img) читают как «картинка внутри карточки» и удивляются, что стиль ушёл на карточку.
Как надо Стилизуется всегда левая часть. Нужен стиль картинке — пиши .card:has(img) img.
:is() с идентификатором внутри
Почему :is(.card, #hero) получает вес идентификатора для обоих случаев. Правило начинает выигрывать там, где не должно.
Как надо Не смешивай уровни в одном :is(). Для базовых стилей бери :where().
Вложенность на четыре уровня
Почему Код выглядит аккуратно, но на выходе получается селектор из пяти частей, который потом нечем перебить.
Как надо Два уровня максимум. Глубже — заводи отдельный класс с осмысленным именем.
Забыт пробел или комбинатор внутри :has()
Почему .card:has(.title) и .card:has(> .title) — разные вещи, а выглядят похоже.
Как надо Без комбинатора — любой потомок на любой глубине. Нужен прямой ребёнок — ставь >.
- Родительский селектор :has()
- Псевдокласс, выбирающий элемент по наличию подходящего потомка или соседа.
- Группировка :is()
- Сокращение для списка селекторов. Специфичность — по самому тяжёлому аргументу.
- Нулевая специфичность :where()
- То же, что
:is(), но вес всегда ноль. Основа для базовых стилей. - Вложенность CSS CSS nesting
- Запись правил внутри других правил через
&, без препроцессора.
Проверь себя
Стилизуется всегда левая часть селектора. <code>:has()</code> — это условие, а не цель.
<code>:where()</code> всегда весит ноль, независимо от содержимого. Остаётся только <code>.title</code> — точнее, один класс: 0-1-0.
Без комбинатора внутри скобок подходит любой потомок. Со знаком <code>></code> — только непосредственный.
Незнакомый псевдокласс обнуляет весь селектор. Страница остаётся в базовом виде — это безопасная деградация.
Верных вариантов несколько.
Аккордеон — это интерактив с состоянием и ролями. CSS их не сообщает: программе чтения с экрана такой аккордеон останется непонятным.
Задание
Четыре состояния без JavaScript
Перед тобой каталог. Нужно сделать четыре вещи одними селекторами, не трогая разметку:
- карточку с бейджем
.badge— с рамкой цвета#c8394a; - карточку без картинки — с фоном
#f4f4f8; - заголовки внутри
.catalogи.aside— одним правилом через:is(); - базовый отступ абзаца — через
:where(), чтобы класс его легко перебивал.
Подсказка 1
Условие пишется в скобках, а красится то, что слева: .card:has(.badge) { border-color: #c8394a; }
Подсказка 2
Отсутствие проверяется через :not(:has(...)) — «карточка, внутри которой нет img».
Подсказка 3
:where(p) { margin-bottom: 12px; } — вес нуль, поэтому .tight { margin: 0 } победит без борьбы.
Показать решение
<section class="catalog"> <h2>Каталог</h2> <article class="card"> <span class="badge">-30%</span> <img src="" alt="Кофе" width="60" height="40"> <h3>Раф</h3> <p>Вкусно</p> </article> <article class="card"> <h3>Эспрессо</h3> <p class="tight">Без картинки</p> </article></section><aside class="aside"> <h2>Сбоку</h2></aside>.card:has(.badge) { border-color: #c8394a;}.card:not(:has(img)) { background: #f4f4f8;}:is(.catalog, .aside) h2 { font-size: 20px; margin: 0 0 10px;}:where(p) { margin-bottom: 12px;}.card { display: grid; gap: 6px; padding: 12px; margin-bottom: 10px; border: 2px solid #ddd; border-radius: 10px;}.badge { font-weight: 700; color: #c8394a; }img { background: linear-gradient(135deg, #7c5cff, #b06cff); border-radius: 6px; }.tight { margin: 0; }