Ви доставляєте інтерфейс. Через тиждень хтось додає іконку-підказку всередині обгортки інпута, і раптом червона рамка «invalid» перестає з’являтися.
Або продакт-менеджер хоче сітку карток, яка підсвічує будь-яку картку з бейджем «Deal» — без підключення десяти різних JS-спостерігачів.
Тихий лиходій зазвичай той самий: ми постійно просимо CSS стилізувати вгору по DOM-дереву, а CSS історично відмовлявся. Тепер він цього не робить.
:has() — це селектор батька, який ми хотіли двадцять років, і він нарешті придатний для продакшену — якщо ставитися до нього як до операційної задачі, а не демонстраційного трюку.
Що :has() насправді означає (і чому це інше)
:has() — це реляційний псевдоклас: він відповідає елементу, якщо він містить щось, що відповідає списку селекторів, який ви помістили всередині.
Практичний переклад простий: нарешті можна стилізувати батька на основі стану дитини.
Приклад: підсвітити обгортку поля, якщо вона містить невірний інпут.
cr0x@server:~$ cat ui.css
.field:has(input:invalid) {
border: 2px solid #c1121f;
background: #fff5f5;
}
Це читається як звичайна мова. Що є головною причиною, чому це небезпечно: складні речі виглядають простими.
:has() змінює ваше мислення про структуру DOM, межі компонентів і поширення стану.
Якщо ви використовуєте його правильно, ви видаляєте JS. Якщо використовуєте халтурно — створите тикети «чому весь сторінка перерисовується?».
Чим він не є
- Не замінник доброї HTML-структури. Якщо ваш DOM — як шухляда з мотлохом,
:has()просто допоможе знайти цей мотлох швидше. - Не привід переставати використовувати класи. Ваше майбутнє «я» хоче стабільні хуки для тестування та рефакторингу.
- Не магія. Це все ще матчинг селекторів, все ще впливає на інвалідацію й перекалькуляцію стилів.
Одна операційна підстановка, яка працює: ставтеся до :has() як до додавання нового ребра залежностей у ваш граф стану.
Коли дитина змінюється, стилі батька можуть потребувати перекалькуляції. Оце і є сама суть. Також — її вартість.
Перефразована ідея з кіл інженерної надійності: Оптимізуйте насамперед для відладжуваності; тонке налаштування продуктивності простіше, ніж розуміти чорний ящик.
Це позиція, яку варто зайняти щодо :has(). Використовуйте його, щоб зменшити непрозорий JS-стан, але тримайте селектори читабельними й обмеженими.
Цікаві факти й контекст, які можна повторити на дизайн-рев’ю
Вам доведеться захищати :has() на код-рев’ю, і ви отримаєте класичні питання: «Це підтримується?», «Це повільно?»
«Чому не додати клас?» Ось корисні факти.
- CSS отримав
:has()через Selectors Level 4. Запит на «селектор батька» старий; він затягувався, бо зачіпає продуктивність і інвалідацію. - jQuery мав
:has()задовго до CSS. Це частково причина, чому інженери чекали, що нативний CSS зробить те саме. Він не зробив. - Браузери роками вагались через «зворотні залежності». Традиційно залежності стилів текли від батька до дитини;
:has()може це інвертувати. - Safari випустив це раніше. Це здивувало людей, які думають, що Safari постійно відстає. У цьому випадку — ні.
- Двигуни браузерів покращили відстеження інвалідації. Ефективно знати, які предки можуть відповідати селектору при зміні ноди — справжня інженерія, не просто зневага.
:has()дозволяє стилювати стан без зайвого DOM. Раніше команди часто додавали обгортки лише як хуки для стилізації; це фактично технічний борг у формі HTML.- Він добре поєднується з сучасними псевдокласами форм.
:user-invalid,:invalid,:required,:placeholder-shownі:focus-withinстають кориснішими, коли їхній ефект можна підняти вгору. - Він добре працює з ARIA-станом. Орієнтація на
[aria-expanded="true"]або[aria-invalid="true"]всередині:has()відмінно відображає доступний стан UI.
Жарт №1: Люди називають :has() «селектор батька», що точно — як називати дата-центр «кімнатою з комп’ютерами».
Форми: валідація, обов’язкові поля та «просто працюючі» обгортки помилок
Реальні формові інтерфейси — це не просто інпут і кнопка відправки. Це обгортки, іконки, мітки, допоміжний текст, серверні помилки, клієнтські обмеження
і момент, коли хтось додає inline-перемикач «показати/сховати» у полі пароля.
Стабільний патерн: стилізуйте обгортку, а не інпут. Але обгортка має реагувати на стан інпута. Ось де працює :has().
Стан invalid на рівні обгортки
cr0x@server:~$ cat form.css
.field {
border: 1px solid #ccd5e1;
border-radius: 10px;
padding: 10px;
display: grid;
gap: 6px;
}
.field:has(input:invalid) {
border-color: #c1121f;
background: #fff5f5;
}
.field:has(input:invalid) .hint {
color: #c1121f;
}
.field:has(input:focus-visible) {
box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.2);
}
Це уникає класичної пастки: стилізувати лише інпут, тоді як фактична область кліку — обгортка.
Тепер ваше «кільце помилки» охоплює і кнопку-іконку, і стрілку селекту, і префіксний лейбл — все.
Маркування обов’язкових полів без зайвих класів
Ви можете позначати обов’язкові поля на основі наявності дітей з :required.
cr0x@server:~$ cat required.css
.field label::after { content: ""; }
.field:has(:required) label::after {
content: " *";
color: #c1121f;
}
Тут потрібно дисципліна: якщо ваша обгортка може містити кілька інпутів (наприклад, діапазон дат),
вирішіть, чи «required» означає наявність будь-якого обов’язкового інпута або всіх обов’язкових інпутів. Потім закодуйте це.
Інакше ви відправите несумісний спам зірочок.
Серверні помилки та стилізація на основі ARIA
Клієнтська валідність — не вся історія. У продакшені сервер відхиляє значення: унікальність, політики,
«цей купон з 2019 року, будь ласка, зупиніться». Ці стани часто повертаються як ARIA-атрибути в HTML.
cr0x@server:~$ cat aria.css
.field:has([aria-invalid="true"]) {
border-color: #c1121f;
}
.field:has([aria-invalid="true"]) .hint {
color: #c1121f;
font-weight: 600;
}
Тут також хороша командна політика: надавайте перевагу ARIA-стану перед кастомними атрибутами «data-error»,
коли вони описують те саме. Це тримає доступність і стилізацію узгодженими.
Умовний допоміжний UI: показувати попередження «Caps Lock увімкнено» лише коли воно є
Ви можете умовно виділяти місце й уникати зсуву макета, стилізуючи на основі присутності елемента-підказки.
Без JS. Та й без порожніх заповнювачів.
cr0x@server:~$ cat helper.css
.field .caps-warning { display: none; }
.field:has(.caps-warning[data-visible="true"]) .caps-warning {
display: block;
color: #8a6d3b;
}
Якщо ви робите це правильно, JS лише виставляє data-visible на самій підказці.
Обгортка реагує, і вам не потрібно прокидатися по всьому дереву компонентів, щоб проганяти класи.
Картки: стилізація, яка знає вміст, без JS-склеювання
Картки — місце, де дизайн-команди страждають поволі. Маркетинг хоче бейджі, редакція — підзаголовок,
продакт — всю картку клікабельною, але при цьому є іконка закладок, яка не має переадресовувати.
Потім додаються A/B тести з випадковими лейблами. Чудово.
:has() дозволяє стилізувати картку залежно від того, що всередині — без того, щоб шаблонна система впихувала класи стилів скрізь.
Правильний підхід — використовувати :has() для внутрішніх рішень компоненту. Якщо стан походить зовні компоненту,
краще використовувати явні класи/пропси.
Підсвітити картки, що містять бейдж «Deal»
cr0x@server:~$ cat cards.css
.card {
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 14px;
background: white;
}
.card:has(.badge--deal) {
border-color: #f59e0b;
box-shadow: 0 8px 26px rgba(245, 158, 11, 0.18);
}
.card:has(.badge--deal) .title {
color: #92400e;
}
Ви не додавали .card--deal. Ви не міняли бекенд. Ви не писали скрипт «скануй DOM на наявність бейджів».
Ви просто стилізували реальний компонент на основі реального вмісту.
Картки з діями: змінювати відступ, коли існує ряд дій
Класична проблема: деякі картки мають футер з кнопками; деякі — ні. Відступи виглядають незграбно.
Раніше команди вирішували це дублюванням шаблонів або пропом «hasFooter». Тепер це просто CSS.
cr0x@server:~$ cat card-footer.css
.card { padding-bottom: 14px; }
.card:has(.card__actions) {
padding-bottom: 10px;
}
.card:has(.card__actions) .card__actions {
margin-top: 12px;
border-top: 1px solid #eef2f7;
padding-top: 10px;
}
Зверніть увагу на область пошуку: ми дивимося тільки на .card__actions всередині .card.
Це перше рішення з продуктивності, яке ви приймаєте.
Клікабельні картки без поламаних вкладених посилань
Багато команд обгортають всю картку в якір (anchor). Потім вони вбудовують інші якори, і ви отримуєте некоректний HTML або дивну поведінку кліку.
Краще зберегти основне посилання всередині і стилізувати картку, коли вона містить це посилання.
cr0x@server:~$ cat clickable-card.css
.card:has(a.card__primary-link:hover) {
transform: translateY(-1px);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.card:has(a.card__primary-link:focus-visible) {
outline: 3px solid rgba(0, 120, 212, 0.35);
outline-offset: 3px;
}
Це дає ефект «вся картка здається інтерактивною» без HTML-порушень.
Посилання залишається семантичним ціллю. Картка візуально реагує.
Фільтри та фасетний пошук: перемикачі, лічильники та станні панелі
Фільтри — це реальність продакшену: багато чекбоксів, перемикачів, пігулок і логіки «Очистити все».
Стан UI має тенденцію розростатись: бічна панель має знати, чи щось всередині вибране; кожна група фільтрів потребує індикатора «dirty»;
кнопка «застосувати» повинна бути активною лише коли зміни існують; рядок підсумку має показувати лічильники.
:has() не порахує елементи (CSS не є рушієм запитів), але він вирішує бінарні питання, які багато в чому визначають полірованість UI:
«щось вибрано?», «ця група активна?», «чи група містить невірний інпут?», «панель розгорнута?»
Позначати активні групи фільтрів, якщо будь-який чекбокс позначено
cr0x@server:~$ cat filters.css
.filter-group {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 10px;
}
.filter-group:has(input[type="checkbox"]:checked) {
border-color: #2563eb;
background: #eff6ff;
}
.filter-group:has(input[type="checkbox"]:checked) .filter-group__title::after {
content: " (active)";
font-weight: 600;
color: #2563eb;
}
Ви щойно ліквідували цілу категорію JS: ітерації по групах, перемикання класів, обробку мутацій DOM.
Розмітка керує станом. Це правильний напрямок.
Активувати кнопку «Очистити фільтри», коли щось вибрано
Класична проблема UX: кнопка вимкнена, доки вона недоречна.
Ви можете стилізувати кнопку на основі позначених інпутів у контейнері.
cr0x@server:~$ cat clear.css
.filters .clear {
opacity: 0.4;
pointer-events: none;
}
.filters:has(input:checked) .clear {
opacity: 1;
pointer-events: auto;
}
Це не тільки естетика. Це запобігає клікам без дії та зменшує спам на бекенд («очистити» спамується без фільтрів).
Маленькі поліпшення UI дають відчутний операційний ефект.
Стилізація акордеону за ARIA
cr0x@server:~$ cat accordion.css
.filter-group:has(button[aria-expanded="true"]) {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
.filter-group:has(button[aria-expanded="true"]) .filter-group__chevron {
transform: rotate(180deg);
}
Знову ж: ARIA-атрибут — це стан. CSS реагує. JS лише перемикає aria-expanded на кнопці,
що він і має робити для доступності.
Жарт №2: Найкраща особливість :has() — він скорочує «дрібні стейт-машини» в JS, бо їх точно потрібно було менше.
Ментальна модель продуктивності: коли :has() дешево vs. гаряче
Страх навколо :has() не безпідставний. Він може збільшити обсяг роботи браузера при зміні DOM,
бо вводить селектори, матч яких залежить від нащадків.
Але реальний світ складніший: багато інтерфейсів кепкуються через JavaScript, layout, зображення або мережу.
Якщо :has() видаляє JS-спостерігачі і зменшує перерендери, це може бути чистим виграшем.
Питання не «чи :has() повільний?», а «ви зробили його необмеженим?»
Роби: обмежуйте :has() коренями компонентів
Добре: .field:has(input:invalid), .card:has(.badge--deal), .filters:has(input:checked).
Вони обмежені класом кореня компоненту і зазвичай охоплюють невелике піддерево.
Не роби: писати глобальні селектори, що сканують весь сайт
Погано: body:has(input:invalid) (ви щойно зробили сторінку реагуючою на будь-який невірний інпут),
або main:has(.badge) коли є тисячі бейджів.
Знайте, що тригерить перекалькуляцію
- Зміни атрибутів, що впливають на внутрішній селектор (наприклад, перемикання
aria-expanded,checked,disabled). - Додавання/видалення нащадків, які відповідають внутрішньому селектору (мутації DOM).
- Зміни стану як
:hover,:focus,:invalid— ці події можуть відбуватися часто.
Операційне правило великого пальця
Якщо внутрішній селектор може змінюватися на кожному русі миші (:hover) в межах великого піддерева, вважайте це ризиком продуктивності.
Якщо він змінюється лише при дискретних діях (перемикання чекбоксів, відправка форми, розгортання), зазвичай усе гаразд.
Це не голослівно. Це те саме, що ми робимо в операціях: дорогі роботи можна дозволити під час розгортання або інцидентів.
На кожен запит таке не потягнеш.
Практичні завдання (команди, вивід та рішення)
Ви просили продакшен-рівень. Це означає повторювані перевірки, а не відчуття «так, виглядає нормально».
Нижче практичні завдання, які можна виконати локально або в CI для валідації використання :has(), ризику продуктивності та поведінки запасного варіанту.
Кожне містить: команду, що означає вивід, і рішення.
Завдання 1: Знайти використання :has() по репозиторію
cr0x@server:~$ rg -n --hidden --glob '!**/node_modules/**' ':has\(' .
src/styles/forms.css:12:.field:has(input:invalid) {
src/styles/cards.css:41:.card:has(.badge--deal) {
src/styles/filters.css:7:.filters:has(input:checked) .clear {
Вивід означає: У вас є три місця з :has().
Рішення: Вимагайте, щоб кожне використання було обмежене класом кореня компоненту і перевірялося на волатильність внутрішнього селектора.
Завдання 2: Виявити ризикові глобальні патерни з :has()
cr0x@server:~$ rg -n ':has\(' src/styles | rg -n '^(html|body|main|#app|\.app|\.page)'
src/styles/layout.css:3:body:has(.modal-open) {
Вивід означає: Є глобальне використання body:has(...).
Рішення: Або обґрунтуйте його (стан модального вікна може бути стабільним), або рефакторьте в явний клас стану на body.
Завдання 3: Переконатися, що збірка не «поліфілить» :has() у нісенітницю
cr0x@server:~$ node -p "require('./package.json').browserslist"
[ 'defaults', 'not IE 11', 'maintained node versions' ]
Вивід означає: Ваш Browserslist сучасний. Добре.
Рішення: Підтвердіть, що CSS-пайплайн не намагається трансформувати :has(). Краще залишити його незмінним; часткові поліфіли можуть бути гірші за відсутність підтримки.
Завдання 4: Перевірити, що ваша згенерована CSS фактично містить селектори
cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 182K dist/assets/app.css
Вивід означає: У вас є скомпільований stylesheet для інспекції.
Рішення: Прогрійте його, щоб переконатися, що селектори :has() переживають мінімізацію і не дублюються надмірно.
Завдання 5: Підтвердити, що :has() пережив компіляцію/мінімізацію
cr0x@server:~$ rg -n ':has\(' dist/assets/app.css | head
1:.field:has(input:invalid){border-color:#c1121f}
1:.filters:has(input:checked) .clear{opacity:1;pointer-events:auto}
Вивід означає: Селектор присутній у відвантажених ресурсах.
Рішення: Продовжуйте з progressive enhancement; не покладайтеся на перезапис під час збірки.
Завдання 6: Виміряти зміну розміру CSS при видаленні JS-класів
cr0x@server:~$ gzip -c dist/assets/app.css | wc -c
42191
Вивід означає: Gzipped CSS близько 42KB.
Рішення: Якщо заміна JS-тоглів класів на :has() зменшує JS більше, ніж збільшує CSS, це часто чистий виграш для інтерактивності сторінки.
Завдання 7: Знайти JS-код, що перемикає класи обгорток (кандидат на видалення)
cr0x@server:~$ rg -n "classList\.add\(|classList\.toggle\(" src | rg -n "(invalid|error|has-|active|dirty)"
src/ui/formField.ts:88:wrapper.classList.toggle("is-invalid", !input.checkValidity())
src/ui/filters.ts:52:group.classList.toggle("is-active", anyChecked)
Вивід означає: Ви вручну поширюєте стан дитини на класи батька.
Рішення: Замініть там, де безпечно, на :has() і залиште JS для поведінки та доступності, а не для стану стилю.
Завдання 8: Підтвердити, що у продакшені немає значущого трафіку з непідтримуваних браузерів (за логами)
cr0x@server:~$ zgrep -h "User-Agent" /var/log/nginx/access.log* | head -n 3
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36
Вивід означає: Сучасні рушії домінують у вибірці.
Рішення: Якщо у вас досі довгий хвіст старих корпоративних браузерів, розробіть запасні варіанти: функціональний UI насамперед, покращена стилізація — другорядно.
Завдання 9: Переконатися, що критичний UI працює без :has()
cr0x@server:~$ chromium --user-data-dir=/tmp/chromium-has-test --disable-features=CSSHasPseudoClass https://localhost:5173/
[19023:19023:1229/120102.103129:INFO:chrome_main_delegate.cc(785)] Starting Chromium...
Вивід означає: Ви запустили Chromium з відключеним :has() (назва feature-flag може відрізнятися).
Рішення: Якщо помилки форм стають невидимими, ви провалили progressive enhancement. Тримайте повідомлення про помилки видимими за замовчуванням; використовуйте :has() для полірування.
Завдання 10: Записати трасування продуктивності з фокусом на перекалькуляцію стилів
cr0x@server:~$ chromium --enable-logging=stderr --v=1 https://localhost:5173/
[19077:19077:1229/120222.411283:INFO:content_main_runner_impl.cc(1007)] Starting content main runner
Вивід означає: Chrome логгірує; вам все ще потрібна панель Performance у DevTools для реального трейсингу.
Рішення: Якщо при перемиканні чекбокса з’являються довгі «Recalculate Style» відрізки, ревізуйте селектори: зменшіть область, уникайте hover-базованого :has() на великих контейнерах.
Завдання 11: Лінтувати «selector bombs» (дуже довгі ланцюги нащадків)
cr0x@server:~$ rg -n ':has\([^)]{60,}\)' src/styles
src/styles/legacy.css:19:.page:has(.content .grid .card .meta .badge[data-type="x"])
Вивід означає: Хтось написав довгий, ламкий десендентний ланцюжок всередині :has().
Рішення: Замініть на стабільний хук-клас як .badge--x або реструктуруйте розмітку. Довгі ланцюги — це крихкість, а не хитрість.
Завдання 12: Перевірити, що логіка селекторів CSS не випадково не матче декілька станів
cr0x@server:~$ node -e 'const s=[".field:has(input:invalid)",".field:has(:required)","body:has(.modal-open)"]; console.log(s.join("\n"))'
.field:has(input:invalid)
.field:has(:required)
body:has(.modal-open)
Вивід означає: Це друкує селектори, які ви плануєте відправити (використайте це в CI як швидкий смок-тест разом із grep).
Рішення: Вимагайте явного огляду будь-якого селектора, що таргетує body/html або використовує високочастотні псевдокласи як :hover всередині :has().
Завдання 13: Підтвердити, що HTML компонентів містить очікувані хуки для селекторів
cr0x@server:~$ rg -n 'class="field"' src | head
src/pages/signup.html:21:<div class="field">
src/pages/settings.html:44:<div class="field">
Вивід означає: Ваші шаблони мають послідовні класи-обгортки.
Рішення: Стандартизуйте імена обгорток (.field, .filter-group, .card), щоб :has() залишався локальним та передбачуваним.
Завдання 14: Ловити регресії, дифуючи структуру DOM для критичних компонентів
cr0x@server:~$ git diff --stat origin/main...HEAD -- src/components/FormField.html
src/components/FormField.html | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
Вивід означає: Структура компоненту змінилася (навіть якщо кількість рядків та ж).
Рішення: Запустіть швидкі візуальні регресійні або DOM-снапшот-тести при зміні структури, бо :has() від цього залежить.
Швидкий план діагностики
Коли хтось каже «після додавання :has() сторінка стала деренчати», не сперечайтесь. Діагностуйте.
Вузьке місце зазвичай одне з трьох: область селектора, частота інвалідації або треш макета від застосованих стилів.
По-перше: ідентифікуйте селектор, що розширює набір матчингу
- Шукайте глобальні корені:
html:has,body:has,main:has,#app:has. - Шукайте широкі внутрішні селектори:
:has(*:hover),:has(:focus),:has(.thing), де.thing— загальний на всьому сайті. - Рішення: звузьте до кореня компоненту або додайте присвячений hook-клас, який з’являється лише там, де треба.
По-друге: перевірте, що саме тригерить перекалькуляцію
- Якщо це
:hoverабо:focus, ви зробили залежним від частоти подій. - Якщо це
:checkedабоaria-expanded, це залежить від частоти дій (зазвичай безпечніше). - Рішення: уникайте hover-орієнтованого
:has()на великих контейнерах; тримайте hover-ефекти на самому елементі або на малому предку.
По-третє: інспектуйте застосовані стилі (класична пастка «виглядає невинно»)
- Тіні і фільтри можуть бути дорогими при застосуванні широко.
- Зміни властивостей, що впливають на макет (як
display,position,height), можуть викликати перерозташування. Іноді це нормально, іноді катастрофа. - Рішення: тримайте ефекти
:has()в межах властивостей лише для paint (колір рамки, фон, outline, колір тексту), якщо ви не профайлнули інакше.
По-четверте: підтвердьте поведінку fallback
- Якщо браузер не підтримує
:has(), чи все ще UI повідомляє про помилки та стан? - Рішення: показуйте текст помилок за замовчуванням (або при відправці), і використовуйте
:has()для поліпшень оформлення.
Поширені помилки: симптом → корінна причина → виправлення
1) «Вся сторінка мерехтить, коли я наведу на сітку карток.»
Симптом: Наведення на будь-яку картку викликає помітний перерендер або тремтіння.
Корінна причина: Великий предок використовує :has(:hover) або подібне, що викликає часту перекалькуляцію стилів по великому піддереву.
Виправлення: Застосовуйте hover-стилі безпосередньо до наведуваного елемента, або обмежте :has() до самої картки: .card:has(:hover) все ще дивно; краще .card:hover і використовуйте :has() для не-hover станів.
2) «Невірні поля не підсвічуються в деяких браузерах.»
Симптом: Рамки обгорток не стають червоними; користувачі пропускають помилки.
Корінна причина: Покладання на :has() для критичної видимості помилок без запасного варіанту.
Виправлення: Зробіть повідомлення про помилки видимими та інпути стилізованими базово; прикраса обгортки — це enhancement. Якщо потрібна широка підтримка, тримайте мінімальний клас .is-invalid як fallback.
3) «Одна обгортка поля червона, хоча інпут виглядає валідним.»
Симптом: Обгортка показує стиль invalid, але призначений інпут в нормі.
Корінна причина: Обгортка містить кілька інпутів, і один прихований або не пов’язаний інпут є invalid.
Виправлення: Звузьте внутрішній селектор до конкретного контролю: .field:has(input.field__control:invalid). Не використовуйте input:invalid, якщо в обгортці є кілька інпутів.
4) «Наша картка отримала стилізацію ‘deal’, бо всередині є невідповідний бейдж.»
Симптом: Хибні позитиви: стилі спрацьовують тоді, коли не повинні.
Корінна причина: Внутрішній селектор занадто загальний (наприклад, :has(.badge) замість :has(.badge--deal)).
Виправлення: Використовуйте явні модифікаторні класи для семантики. :has() не дає права на розпливчасті селектори.
5) «Кнопка Очистити фільтри активна, але насправді ніяких фільтрів не застосовано.»
Симптом: UI індикує стан, якого бекенд не знає.
Корінна причина: Бічна панель містить чекбокси як для «застосованих», так і для «чернеток»; :has(input:checked) не розрізняє.
Виправлення: Додайте атрибут або клас для позначення застосованого стану: .filters:has(input[data-applied="true"]:checked), або розділіть області DOM для застосованих і чернеток.
6) «Невеликий рефактор DOM поламав половину стилів.»
Симптом: Зміни розмітки змінили візуальну поведінку несподівано.
Корінна причина: :has() залежить від відносин у DOM; крихкі десендентні ланцюжки посилили цю залежність.
Виправлення: Тримайте селектори :has() неглибокими і покладайтеся на стабільні хуки всередині компоненту. Додайте snapshot/візуальні регресійні тести для компонентів з реляційними селекторами.
Контрольні списки / покроковий план
Безпечне впровадження :has() (покроково)
- Виберіть один тип компоненту. Почніть з полів форм або груп фільтрів. Не робіть рефактор по всьому сайту.
- Визначте кореневий селектор. Приклад:
.field,.filter-group,.card. Якщо такого немає — додайте. - Виберіть внутрішні селектори, що стабільні і специфічні. Приклад:
input.field__control:invalid,.badge--deal,button[aria-expanded="true"]. - Зробіть базовий UX працюючим без
:has(). Помилки повинні читатися. Кнопки повинні працювати. Жоден критичний стан не повинен комунікуватися лише через оформлення обгортки. - Використовуйте
@supports selector(:has(*))для ризикових покращень. Це не завжди обов’язково, але чистий запобіжник, якщо ви змінюєте макет або ховаєте/показуєте елементи. - Профілюйте одну взаємодію. Переключіть чекбокс, фокусні інпути, розгорніть акордеон. Подивіться на довгі «Recalculate Style» відрізки.
- Видаліть JS, який лише поширював класи. Залиште JS, що відповідає за поведінку й доступність.
- Додайте регресійний тест. Візуальні снапшоти для станів компонентів: за замовчуванням, фокус, invalid, активний, розгорнутий.
Чекліст якості селекторів (друкована версія для розуму)
- Чи лівий бік — це корінь компоненту (а не
body)? - Чи внутрішній селектор вузький (не довгий десендентний ланцюжок)?
- Чи внутрішній селектор змінюється з високою частотою (hover/mouse move)? Якщо так, перегляньте підхід.
- Чи приховані або нерелевантні нащадки випадково не матчатимуть?
- Чи буде це прийнятно, якщо компонент рендериться 200 разів на сторінці?
- Чи UX все ще прийнятний без
:has()?
Три корпоративні міні-історії з практики
Міні-історія 1: Інцидент, спричинений неправильною припущенням
Команда модернізувала велику сторінку налаштувань: багато повторюваних секцій форм, вкладених компонентів і патерн «Додати ще».
Вони замінили JS-механізм «invalid wrapper» на .section:has(input:invalid) і відправили це за фіче-флагом.
Здавалося чисто. Тести пройшли. Усім все подобалося.
Потім почалися заявки в саппорт: «Не можу зберегти налаштування; сторінка просто повертає мене назад.» Помилка не в збереженні.
Сторінка скролилась до першої невірної секції при сабміті. Та логіка скролу шукала .is-invalid обгортки
(клас, який старий JS встановлював). Нова CSS-тільки стилізація не встановлювала клас — бо це CSS.
Неправильне припущення було тонким: «Якщо виглядає як невірне — значить так і є, і код може його знайти».
Але стилі — не стан. CSS не дає семантичного сигналу, по якому JavaScript має надійно орієнтуватись.
Вони випадково прибрали семантичний сигнал, від якого залежав інший код.
Виправлення було нудним і правильним: зберегти явний атрибут aria-invalid="true" і оновити код скролу, щоб таргетити його,
при цьому залишити :has() для декоративної обгортки. JS, що перемикав класи, лишився видаленим. Стан залишився доступним.
Урок: використовуйте :has() для презентації, похідної від стану, а не для заміни самого стану.
Якщо інший код має реагувати, дайте йому семантичний маркер як ARIA або data-атрибут.
Міні-історія 2: Оптимізація, що повернулась боком
Інша організація відправила нову сторінку «каталогу» з тисячами карток (так, тисячі).
Інженер вирішив зменшити роботу DOM, видаливши серверні модифікаторні класи з карток.
Замість рендеру .card--featured, шаблон рендерив елемент .badge і віддавав стилі CSS:
.card:has(.badge--featured). Елегантно.
Через тиждень панелі продуктивності показали гіршу взаємодію при скролінгу і фільтрації.
Не повний фаєр, але досить, щоб дратувати мобільних користувачів. DevTools показав високі витрати на перекалькуляцію стилів під час оновлень списку.
Причина не містична: UI фільтрів часто оновлював DOM, і кожне оновлення означало більше роботи з матчингом селекторів по великому списку.
«Оптимізація» також мала приховану вартість: розмітка бейджів була динамічнішою, ніж старий модифікаторний клас.
A/B тести вставляли нові типи бейджів, і тепер кілька правил :has() конкурували. Каскад ускладнилася.
CSS залишився коректним, але передбачити його поведінку стало складніше.
Стратегія відкату була прагматичною: тримати :has() для малих списків і локальних компонентів,
а для масивних повторюваних списків відновити явні модифікаторні класи. CSS став простішим, а движок робив менше реляційних матчів.
Урок: :has() не завжди дешевший за клас. Для великих колекцій з частими DOM-змінами явні класи можуть бути кращою продуктивнісною компромісом.
Міні-історія 3: Нудна, але правильна практика, що врятувала день
Команда платежів ввела :has() для покращення обгорток форм. Вони були обережні: кожне правило сиділо за
@supports selector(:has(*)), а запасні стилі були навмисно «достатньо хорошими».
Вони також написали невеликий компонент-тест, що рендерив поле в чотирьох станах: за замовчуванням, фокус, invalid, серверна помилка.
Через шість місяців партнер вбудував форму платежів в WebView зі старішим рушієм.
Оформлення обгортки не застосувалось. Але форма все одно працювала. Повідомлення про помилки було видно, фокусні кільця були,
і процес сабміту був нормальний. Жодного інциденту. Жодних ночних повідомлень. Просто невелике «виглядає менш відшліфовано».
Кульмінація: інша команда без цих запобіжників відправила подібну фічу, і в старих рушіях текст помилок був схований за замовчуванням і відкривався лише через :has().
Користувачі не бачили, що пішло не так. Це стало реальною проблемою саппорту.
Нудна практика не була героїчною. Це була: progressive enhancement, явний fallback і тести станів.
Операційний виграш був реальний: менше видимих збоїв у непередбачуваних клієнтських середовищах.
FAQ
1) Чи безпечно використовувати :has() у продакшені?
Так, якщо ви застосовуєте progressive enhancement і уникаєте необмежених селекторів. Ставтеся до нього як до будь-якої сучасної можливості платформи:
визначіть базовий досвід, а потім покращуйте там, де підтримка є.
2) Чи обгорнути правила з :has() в @supports?
Якщо правило впливає на суттєву придатність інтерфейсу (показ/ховання помилок, зміни макета), — так. Якщо це декоративно і вам не страшно, що воно не застосується, — опційно.
Гард виглядає так: @supports selector(:has(*)) { ... }.
3) Чи можна використовувати :has() замість JS-стану?
Замініть поширення стану презентації, а не доменний стан. Якщо код має знати, що щось invalid/expanded/active,
виражайте це через атрибути або класи. CSS потім може вивести оформлення через :has().
4) Чи шкодить :has() продуктивності?
Може, особливо при використанні в великих контейнерах з часто змінними нащадками.
Обмежуйте селектори коренями компонентів і уникайте високочастотних псевдокласів як :hover всередині :has() на великих піддеревах.
Профілюйте конкретну взаємодію, яка вас цікавить.
5) Чи краще :has(), ніж додавання класу як .is-invalid?
Це краще, коли стан уже присутній у DOM (наприклад, :invalid, :checked, ARIA-атрибути) і ви хочете уникнути JS-склеювання.
Клас кращий, коли мова про великі списки, крос-компонентний стан або коли JS уже обчислює стан.
6) Чи можна за допомогою :has() порахувати вибрані фільтри?
Не напряму. CSS не вміє коректно і керовано рахувати. Використовуйте JS для підрахунку, рендерьте число, і застосовуйте :has() для бінарної стилізації як «активний/неактивний».
7) Як відлагоджувати селектори :has()?
Почніть із ізоляції матчу: тимчасово застосуйте гучний outline до лівого селектора, потім звузьте внутрішній селектор.
Тримайте внутрішні селектори короткими та прив’язаними до явних класів, щоб швидко з’ясувати, чому відбулося матчення.
8) Чи може :has() замінити :focus-within?
Не замінити, а доповнити. :focus-within — це ефективний спосіб стилізувати предка, коли фокус всередині.
Використовуйте його для фокусу. Використовуйте :has(), коли потрібні більш складні умови, ніж «будь-який сконцентрований нащадок».
9) Який найкращий перший кейс використання :has()?
Обгортки полів форм, що реагують на :invalid, :required та ARIA-стан. Це видаляє моторошний JS і одразу покращує узгодженість UX.
Наступні кроки, які можна відправити цього тижня
Робіть це по черзі, бо виробничі системи винагороджують нудну послідовність.
- Виберіть одну родину компонентів: обгортки полів, групи фільтрів або картки.
- Додайте або підтвердіть клас кореня компоненту, щоб ваше
:has()залишалось локальним. - Реалізуйте одне реляційне правило, що видаляє існуючий JS-тогл класу. Тримайте стару поведінку за фіче-флагом, якщо сумніваєтесь.
- Обережно обгорніть ризикові покращення з
@supports selector(:has(*))і переконайтесь, що базовий UX все ще комунікує стан. - Запустіть перевірки репозиторію: grep на глобальні селектори, довгі десендентні ланцюжки та hover-важкі патерни.
- Пропрофілюйте одну реальну взаємодію (перемикання фільтра, невірний інпут, розгортання акордеону) і переконайтесь, що перекалькуляція стилів не домінує.
- Додайте невеликий набір регресійних тестів для ключових станів UI, що залежать від
:has().
:has() — одна з тих фіч, що роблять платформу ближчою до того, як ми будуємо UI.
Використовуйте її по-дорослому: обмежено, тестовано, профільовано, з граціозною деградацією. Ваш JS-бандл стане меншим, DOM — чистішим,
і ваша ротація on-call стане тихішою — а це єдиний KPI, що має значення о 2 ранку.