Stylizuj elementy nadrzędne na podstawie ich zawartości za pomocą CSS :has().
Dowiedz się, jak :has() w końcu umożliwia selektorom analizę potomków i rodzeństwa, gdzie mogą być wykorzystywane w otoczeniach microfrontendów oraz dlaczego Shadow DOM w połączeniu z kontraktami class-name nadal ogranicza to podejście.
Kierunki, w które nigdy nie zmierzali selektory CSS
Przez dziesięciolecia selektor CSS mógł opisywać elementy przodków i potomków, a także odnosić się do sąsiadujących elementów tego samego poziomu, ale nie mógł stylizować elementu ze względu na to, co znajdowało się w jego wnętrzu lub po nim. Zapis .card img odszukuje obraz wewnątrz elementu typu card. Zapis h2 + p odnosi się do akapitu następującego bezpośrednio po nagłówku. Odwrotne formy po prostu nie istniały: nie było możliwości stylizacji elementu na podstawie zawartych w nim mediów ani nagłówka na podstawie akapitu znajdującego się później w drzewie struktury.
Ta asymetria nie była przypadkiem wynikającym z preferencji API. Historcznie silniki stosowały style podczas przeglądania dokumentu głównie w kierunku od góry do dołu. Zasada, która musiałaby sprawdzać przyszłe elementy wspólne lub głębokie potomki, zanim zdecyduje, czy dany element pasuje, zniszczyłaby ten model pracy oparty na jednym przeglądaniu. Przez około dwadzieścia pięć lat każdy popularny silnik selektorów przestrzegał tego ograniczenia, a każdy zespół potrzebujący stylizacji kontenera na podstawie jego zawartości radził sobie z tym, zamiast korzystać bezpośrednio z tego mechanizmu: klasa włączana/wyłączana za pomocą JavaScriptu, dodatkowy węzeł otaczający lub właściwość logiczna przechodząca przez kilka komponentów, by jedynie oznaczyć „ten element ma stopkę”.
:has() eliminuje tę obejście, pozwalając samej zasadzie sprawdzać kierunki, w które wcześniej CSS odmawiało patrzenia. Gdy ta możliwość zostanie otwarta, interesującym pytaniem staje się to, co silnik selektorów faktycznie może sprawdzać.
Stylizacja relacyjna zmienia również sposób, w jaki systemy projektowe dokumentują warianty. Zamiast wymagać od autorów pamiętania o klasie modyfikatora za każdym razem, gdy karta zawiera media, arkusz stylów może wywnioskować wariant na podstawie struktury już istniejącej w kodzie. Jest to wygodne, a także tłumaczy, dlaczego przypadkowe zmiany struktury prowadzą do regresji wizualnej.
Czym właściwie zajmuje się :has()
Pseudo-klasa to warunek poprzedzony dwukropkiem, przypisany do selektora. Znane przykłady to :hover i :first-child – pasują one ze względu na stan lub pozycję w strukturze, a nie na nazwę tagu, klasę czy id. :has() należy do tej samej grupy, ale jego argumentem jest inny selektor. Pyta on, czy element główny zawiera – lub jest w jakiś inny sposób powiązany z – czymś, co pasuje do tego argumentu.
.card:has(img) {
border: 2px solid royalblue;
}
Zasada można zrozumieć niemal dosłownie: stosuj tę ramkę do .card tylko wtedy, gdy istnieje odpowiadający element img w kontekście, w którym oceniane jest wyrażenie :has(). Selektor przed :has() określa element, który otrzymuje te deklaracje. Selektor w nawiasach określa jedynie warunek. Gdy warunek jest fałszywy, :has() nie znajduje żadnego dopasowania i zasada w ogóle nie zostaje zastosowana.
Jedno fakty wyjaśniają większość zamieszania związanych z pierwszym użyciem: deklaracje są przypisywane do podmiotu przed :has(), natomiast selektor w nawiasach jest jedynie predykatem. Powszechnym błędnym rozumieniem jest „wybierz obrazek, a następnie stylizuj jego kartę”. W rzeczywistości mechanizm działa w odwrotnym kierunku – przeglądarka najpierw ocenia warunek, a dopiero potem decyduje, czy narysować zewnętrzny podmiot. Ten porządek jest ważny podczas nauki tej funkcji, ponieważ demo pokazujące wewnętrzny selektor skłaniają ludzi do stosowania stylów do niewłaściwego węzła.
Wyrażenie „gdzieś w jego wnętrzu” nadal ukrywa ważne szczegóły. Jak daleko szuka :has() i które relacje są brane pod uwagę?
Potomek, dziecko i rodzeństwo – trzy różne zasięgi
Argument przekazany do :has() nie jest skierowany na „jakikolwiek potomek”. Jest to pełny selektor. Kombinatory – symbole definiujące relacje pomiędzy elementami selektora – zmieniają to, co sprawdza pseudo-klasa.
/* descendant: image anywhere inside, at any depth */
.card:has(img) { }
/* direct child only: image must be an immediate child of .card */
.card:has(> img) { }
/* next sibling: heading immediately followed by a paragraph */
h2:has(+ p) { }
/* any later sibling: some paragraph follows, not necessarily right after */
h2:has(~ p) { }
Każda z tych form sprawdza inny fragment drzewa DOM. Wybór niewłaściwej formy skutkuje cichym niepowodzeniem: nic się nie wywołuje. Wyrażenie .card:has(img), gdy autor miał na myśli .card:has(> img), może wyglądać poprawnie w prostym przykładzie, ale później może pasować do karty, której obraz znajduje się trzy poziomy głębiej po ponownym zaprojektowaniu, gdy dodano kolejny kontener.
W podstawie słownictwo jest niewielkie: sprawdzenie potomków na dowolnej głębokości, ograniczenie dotyczące bezpośrednich dzieci oraz sprawdzanie rodzeństwa – zarówno najbliższego, jak i kolejnych. Kaskady efektów przy przewijaniu, stylizacja walidacji formularzy, wykrywanie stanu pustego oraz podobne wzorce to połączenia tych czterech mechanizmów. Pod nimi nie kryją się już żadne bardziej złożone elementy.
Następnym pytaniem jest to, gdzie te mechanizmy okazują się przydatne w rzeczywistym kodzie produkcyjnym, a nie tylko w dwulinijowym przykładzie demonstracyjnym.
Shell, który reaguje na to, czego sam nie może wyświetlić
Załóżmy aplikację typu shell, która kontroluje układ strony i włącza do niej inne aplikacje: pasek nawigacyjny, widget z listą produktów oraz panel powiadomień. Te komponenty są tworzone i wdrażane niezależnie. Taka struktura w czasie wykonywania nazywana jest zazwyczaj architekturą microfrontend. Zaletą jest to, że zespół odpowiedzialny za powiadomienia może je wdrożyć bez konieczności koordynacji z zespołem aplikacji shell.
Niezależność ma swoją cenę, którą musi ponieść aplikacja shell. Nie może ona z góry wiedzieć, co zostanie wyświetlone w panelu powiadomień, ponieważ oba komponenty są wdrażane według różnych harmonogramów. Jeśli aplikacja shell chce aktualizować główną treść w momencie, gdy panel faktycznie ma coś do pokazania, wcześniejszymi rozwiązaniami były wspólne zdarzenia uruchamiane przy włączaniu treści lub wspólny magazyn stanów, z którego obie strony mogły czytać dane. Każda z tych opcji łączyła repozytoria, które zostały specjalnie podzielone w celu zmniejszenia powiązań między nimi.
/* shell/layout.css */
.main-content:has(~ .notification-panel:not(:empty)) {
margin-right: 320px;
transition: margin-right 0.2s ease;
}
<!-- shell/index.html -->
<div class="app-shell">
<main class="main-content">...</main>
<div class="notification-panel" id="notification-slot"></div>
</div>
// notification-panel/mount.js
import { renderAlert } from './alert.js';
export function mount(targetId, alertData) {
const slot = document.getElementById(targetId);
slot.appendChild(renderAlert(alertData));
}
Użycie :has() oznacza tutaj, że margines zmienia się dopiero wtedy, gdy panel powiadomień zawiera rzeczywisty content, a nie w momencie pojawienia się pustego węzła hosta. Zwróć uwagę na to, czego te pliki stylów nie obejmują: brak autobusu zdarzeń, brak wspólnego magazynu danych, a także brak procedur wymiany sygnałów, które jedna z drużyn musiałaby zaimplementować dla drugiej. Dwie drużyny, które nigdy nie czytały kodu siebie nawzajem, mogą nadal koordynować zmiany w układzie, ponieważ obie modyfikują ten sam żywy DOM.
To brak wyraźnej koordynacji jest dokładnie tym, co sprawia, że kolejne ograniczenia zasługują na nazwę.
Gdzie kończy się zasięg i co zakłada bez słów
Dwie ograniczenia stają się ważniejsze, gdy logika układu obejmuje niezależnie dostarczane fragmenty.
Najważniejsza jest izolacja techniczna. Fragmenty zamknięte w Shadow DOM utrzymują nieprzezroczystą strukturę drzewa. Stylizacje zewnętrzne nie mogą uzyskać dostępu do tej struktury, więc selektor obudowy zależny od pustki wewnątrz nigdy się nie aktywuje. Wspólny, przejrzysty DOM jest warunkiem koniecznym; domyślne zamknięcie w cieniu w komponentach Web Components po cichu dezaktywuje ten model.
Druga ograniczenie ma charakter operacyjny. Zasady układu, które odnoszą się do .notification-panel, zakładają, że nazwa klasy oraz model zawierania pozostają stabilne. Jeśli przemienisz nazwę klasy, przeniesiesz treść do innego elementu lub wyświetlisz ją gdzie indziej, obudowa przestanie reagować, bez błędu typu ani nieudanej testy jednostkowej. Debugowanie w takim przypadku oznacza ponowne odkrycie ukrytej umowy CSS należącej do innego repozytorium.
W porównaniu z wyraźnym zdarzeniem lub wspólnym magazynem danych, nie jest to automatycznie gorsze rozwiązanie. Powiązania wciąż istnieją; po prostu znajdują się w tekście selektorów zamiast w zaimportowanym symbolu.
Składanie elementów z powrotem
Historyczne silniki selektorów były optymalizowane pod kątem przetwarzania danych w jednym kierunku, dlatego zapytania typu container w tym kontekście pojawiły się późno. :has() pozwala wreszcie elementowi reagować na potomków, dzieci oraz kolejne rodzeństwo. Te trzy rodzaje relacji wystarczają, by wyjaśnić zmianę układu struktury – nie jest potrzebny żaden dodatkowy pseudojęzyk.
To samo zasada tłumaczy możliwe błędy. Próba dostępu do drzew cieniowych kończy się niepowodzeniem. Dostęp do elementów na podstawie obcych nazw klas odbywa się bez żadnych komunikatów o błędzie. Efektywne zespoły traktują selektory relacyjne jako narzędzia prezentacyjne powiązane z udokumentowanymi zasadami DOM, a nie jako zamiennik umów serwisowych między właścicielami mikrofrontendów.
Pozycje pokrewne
- Astro w 2026: Strony HTML-First z selektywnymi wyspami — Astro 6 domyślnie przechowuje treść w formacie HTML i aktywuje wyspy React lub Vue tylko tam, gdzie to konieczne. Taka architektura sprawdza się w odpowiednich sytuacjach — choć pełne frameworki SPA nadal są lepsze.
- Struktura, styl, behawior: Jak HTML, CSS i JavaScript dzielą pracę — Przystępna dla początkujących mapa pokazująca, co każdy z tych elementów odpowiada za stronę internetową, jak ze sobą współpracują oraz jakie powszechne błędy zamazują granice między nimi.