css i układy

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 HTML
import { 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 .app przy 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-within istnieje 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 @supports w 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ą.