CSS :has() — rodzic, który wie, co się dzieje w środku
CSS :has() — rodzic, który wie, co się dzieje w środku
Masz pole formularza: <div class="field"> z etykietą i inputem w środku. Wymaganie brzmi banalnie – kiedy input jest niepoprawny, cały kontener ma dostać czerwoną ramkę i ikonę ostrzeżenia obok etykiety, nie tylko sam input. Sięgasz po .field input:invalid – i trafiasz w ścianę. Ten selektor stylowałby input, gdyby chciał czerwonej ramki. Ale Ty chcesz ostylować .field, czyli rodzica tego inputa, na podstawie tego, co dzieje się w jego wnętrzu. I nagle okazuje się, że CSS – mimo dziesiątek kombinatorów, pseudo-klas i selektorów atrybutów – nigdy w swojej 25-letniej historii nie dawał sposobu, żeby to zrobić.
To nie przeoczenie w Twojej wiedzy o CSS. To fundamentalna cecha architektury selektorów, która obowiązywała od CSS1 aż do 2022 roku.
Dlaczego kombinator zawsze patrzy tylko w jedną stronę
Każdy kombinator w CSS – spacja (potomek), > (dziecko bezpośrednie), + (sąsiedni rodzeństwo), ~ (ogólne rodzeństwo) – opisuje relację między dwoma selektorami, ale zawsze stylowany jest element po prawej stronie kombinatora. .field input stylowanego inputa wewnątrz .field. .field ~ .error stylowanego .error, który jest rodzeństwem .field. Kierunek jest zawsze ten sam: od kontekstu do celu, nigdy odwrotnie. Silnik CSS, napotykając .field, nie ma wbudowanego mechanizmu, żeby „zajrzeć do środka” i zmienić na tej podstawie decyzję o stylu samego .field.
Deweloperzy przez lata obchodzili to na dwa sposoby, oba z realnymi kosztami. Pierwszy – JavaScript nasłuchujący na zdarzenie input/blur, ręcznie dokładający klasę .field--invalid do rodzica. Drugi, sprytniejszy, ale kruchy – tzw. checkbox/radio hack, wykorzystujący ~ do stylowania rodzeństwa na podstawie stanu :checked, co działa tylko wtedy, gdy elementy faktycznie są rodzeństwem w płaskiej strukturze DOM, a nie zagnieżdżone tak, jak realny formularz tego wymaga.
jsx
// Obejście sprzed :has() – działa, ale wymaga JS do czegoś,// co jest czysto wizualną konsekwencją stanu HTMLimport { useState } from "react";const FormField = ({ label, ...inputProps }) => {const [isInvalid, setIsInvalid] = useState(false);return (<div className={`field ${isInvalid ? "field--invalid" : ""}`}><label>{label}</label><input{...inputProps}onBlur={(e) => setIsInvalid(!e.target.validity.valid)}/></div>);};
:has() eliminuje ten kod nie dlatego, że jest sprytniejszym trikiem – tylko dlatego, że rozwiązuje problem u źródła, jako pierwsza w historii CSS relacyjna pseudo-klasa, która pozwala elementowi zapytać o własne wnętrze.
Jak :has() naprawdę działa: selektor zakotwiczony w elemencie, nie w potomku
Kluczowe przesunięcie myślenia: .field:has(input:invalid) nie styluje inputa. Styluje .field – dokładnie ten element, do którego przyczepiona jest pseudo-klasa – pod warunkiem, że gdzieś w jego wnętrzu istnieje element pasujący do selektora w środku nawiasu. Element, na którym się „zaczepiasz” (ang. anchor element), zawsze zostaje ten sam; :has() tylko decyduje, czy w ogóle dostanie dopasowanie.
css
.field:has(input:invalid) {border-color: #c62828;}.field:has(input:invalid) .field__icon {visibility: visible;}
Domyślnie selektor wewnątrz :has() szuka dowolnego potomka, na dowolnej głębokości – tak jak zwykła spacja. Ale możesz zawęzić relację dokładnie tak samo, jak w zwykłym CSS, używając kombinatorów wprost w środku nawiasu:
css
/* Tylko bezpośrednie dziecko, nie dowolny potomek */.card:has(> img) {grid-template-columns: 120px 1fr;}/* Element, po którym BEZPOŚREDNIO następuje .error-message jako rodzeństwo */.field:has(+ .field-hint) {margin-bottom: 4px;}
To sprawia, że :has() nie jest jedną nową sztuczką, tylko generalizacją – pozwala wyrazić w CSS każdą relację, jaką wcześniej dało się opisać kombinatorem, tyle że skierowaną „do wewnątrz” albo „wstecz”, zamiast wyłącznie „do przodu”.
Praktyczny przykład: pole formularza, które samo wie, że jest niepoprawne
Połączmy to w kompletny, dostępny komponent. Ważny szczegół: :invalid samo w sobie uruchamia się natychmiast po załadowaniu pustego pola z atrybutem required – zanim użytkownik zdążył cokolwiek wpisać, co dawałoby czerwoną ramkę na pustym, nietkniętym formularzu. Rozwiązuje to :not(:placeholder-shown), które dopasowuje pole tylko wtedy, gdy użytkownik faktycznie już coś w nim zostawił.
jsx
const FormField = ({ label, id, ...inputProps }) => (<div className="field"><label htmlFor={id}>{label}</label><input id={id} {...inputProps} /><svg className="field__icon" aria-hidden="true" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 12H9v-2h2v2zm0-4H9V5h2v5z" /></svg></div>);export default FormField;
css
.field {position: relative;border: 1px solid #ccc;border-radius: 8px;padding: 8px 12px;transition: border-color 0.15s ease;}.field__icon {position: absolute;right: 12px;top: 50%;transform: translateY(-50%);width: 18px;fill: #c62828;visibility: hidden;}/* Pole jest "dotknięte" (coś wpisano) I niepoprawne – dopiero wtedy reaguj */.field:has(input:not(:placeholder-shown):invalid) {border-color: #c62828;background: #fff5f5;}.field:has(input:not(:placeholder-shown):invalid) .field__icon {visibility: visible;}/* Pozytywne potwierdzenie jest równie ważne co błąd */.field:has(input:not(:placeholder-shown):valid) {border-color: #2e7d32;}
Ani jednej linijki JavaScriptu odpowiedzialnej za wygląd. Stan walidacji już istnieje natywnie w przeglądarce – :valid/:invalid są tam od CSS3 – :has() po prostu w końcu pozwolił przenieść ten istniejący stan z inputa na jego kontener, tam gdzie wizualnie potrzebny był od zawsze.
Drugi wzorzec: komponent, który wie, co zawiera
Ten sam mechanizm rozwiązuje zupełnie inną klasę problemów – layout zależny od obecności konkretnej zawartości, bez propsa informującego o tym z góry.
css
/* Karta z obrazem dostaje układ dwukolumnowy, karta bez obrazu – pełną szerokość tekstu */.card:has(> img) {display: grid;grid-template-columns: 96px 1fr;gap: 12px;}/* Nagłówek sekcji dostaje odstęp od dołu tylko, jeśli sekcja faktycznie ma podtytuł */.section:has(> .section__subtitle) .section__title {margin-bottom: 4px;}/* Lista bez żadnego elementu pokazuje stan pusty zamiast pustej przestrzeni */.product-list:not(:has(li)) {display: block;}.product-list:not(:has(li))::after {content: "Brak produktów spełniających kryteria.";color: #666;}
Bez :has() każdy z tych przypadków wymagałby albo propsa (hasImage, isEmpty) ustawianego ręcznie przez komponent nadrzędny, albo sprawdzania długości tablicy w JSX i warunkowego renderowania osobnej klasy. :has() pozwala komponentowi samemu rozpoznać własną zawartość i zareagować, zamiast być o niej informowanym z zewnątrz – to dokładnie ten sam kierunek myślenia, co karta produktu świadoma własnego kontenera z artykułu o Container Queries: mniej stanu przekazywanego ręcznie, więcej logiki wynikającej wprost ze struktury.
Pułapki i dobre praktyki
- Specyficzność
:has()to specyficzność najbardziej specyficznego selektora w środku, nie zero..field:has(input:invalid)ma specyficzność dwóch klas i pseudo-klasy razem – nie licz na to, że:has()„nie liczy się” do specyficzności, bo w praktyce regularnie przebija prostsze reguły pisane później w arkuszu. - Głęboko zagnieżdżone, szerokie
:has()bez kombinatora potrafi kosztować..app:has(.some-deeply-nested-element)teoretycznie każe silnikowi rozważyć całe poddrzewo.appprzy każdej zmianie DOM w jego wnętrzu. Nowoczesne silniki (Chromium, WebKit) optymalizują to przez tzw. invalidation sets – nie przeliczają wszystkiego na ślepo – ale efekt zależy od konkretnego selektora i rozmiaru drzewa. Zawężaj relację kombinatorem (>, bezpośrednie dziecko) tam, gdzie to możliwe, zamiast domyślnego przeszukiwania w głąb. :has()nie zastępuje:focus-within. Jeśli potrzebujesz tylko „rodzic reaguje, gdy potomek ma fokus”,:focus-withinistnieje od dawna, jest tańszy do wyliczenia i bardziej czytelny –:has(:focus)da praktycznie ten sam efekt, ale bez powodu, żeby sięgać po ogólniejsze narzędzie tam, gdzie specjalizowane już istnieje.- Wsparcie przeglądarek przestało być problemem.
:has()wylądował jako ostatni duży element układanki – Safari miało go od 15.4 (marzec 2022), Chrome od 105 (sierpień 2022), Firefox dołączył ostatni, w wersji 121 (grudzień 2023). Od tamtej pory jest bezpieczny do użycia bez@supportsw każdym nowym projekcie.
Podsumowanie
Przez 25 lat CSS pozwalał opisywać tylko relacje skierowane w jedną stronę – od kontekstu do celu, od rodzica do dziecka, od poprzednika do następnika. :has() nie jest kolejnym wariantem tego samego pomysłu, tylko pierwszą pseudo-klasą, która pozwala elementowi zapytać o własne wnętrze i zareagować na to, co tam znajdzie – błąd walidacji w polu, obecność obrazu w karcie, brak elementów na liście. To przesuwa całą klasę decyzji, które wcześniej musiały trafiać do JavaScriptu albo do propsów przekazywanych ręcznie z góry, z powrotem tam, gdzie należą: do struktury HTML i reguł CSS, które tę strukturę opisują.