Szczęśliwa młoda kobieta i mała dziewczynka pracują online, oglądają webinarium, podcast na laptopie, prowadzą zdalną rozmowę w domu wokół jesiennych liści

Jak stylować rodzica w CSS? Selektor :has() i :focus-within

11 min. czytania

Stylowanie rodzica na podstawie stanu dziecka to coś, czego webdeveloperzy chcieli od lat – zwłaszcza przy budowaniu formularzy, nawigacji i komponentów interaktywnych z zachowaniem wysokiej dostępności. Dzięki pseudoklasom :focus-within i :has() CSS wreszcie to potrafi – i to bez JavaScriptu.

Problem – „chcę ostylować rodzica, gdy dziecko ma focus”

Przykład, który zna każdy: mamy kontener .field z etykietą i polem <input>. Chcemy, aby całe pole (ramka, tło) podświetlało się, gdy focus ma input w środku – tak jak w nowoczesnych formularzach.

Przez lata CSS działał tylko „w dół” drzewa DOM: mogliśmy wybrać dzieci na podstawie rodzica, ale nie odwrotnie. Aby zmienić styl rodzica na podstawie dziecka (np. input:focus), trzeba było używać:

  • JavaScriptu / jQuery (focus / blur + classList.add/remove()),
  • sztuczek z dodatkowym elementem (np. pseudo‑elementy, z-index),
  • albo projektować HTML inaczej niż by się chciało.

Specyfikacja Selectors Level 4 wprowadziła wreszcie funkcjonalną pseudoklasę :has(), która pozwala wybrać element, jeśli ma zawarte w sobie elementy spełniające dany selektor – czyli w praktyce pozwala stylować rodzica na podstawie dzieci. Zanim jednak :has() zaczęło być szeroko wspierane, pojawiła się pseudoklasa :focus-within, rozwiązująca przynajmniej ten jeden, bardzo częsty przypadek – focusu.

Podstawy: jak działa :focus-within?

Pseudoklasa :focus-within wybiera element, jeśli on sam ma focus lub którykolwiek z jego potomków ma focus.

Innymi słowy: to odpowiednik „ma focus gdzieś w środku”.

Przykład z MDN‑owskiej definicji da się streścić tak:

:focus-within reprezentuje element, który sam pasuje do :focus, albo ma potomka, który pasuje do :focus.

Prosty przykład z formularzem

HTML:

<div class="field">
<label for="email">E-mail</label>
<input id="email" type="email">
</div>

CSS:

.field {
border: 1px solid #999;
padding: 0.75rem;
border-radius: 0.5rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.field:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}

Gdy użytkownik przeniesie focus na <input>, focus nie jest na .field, ale .field:focus-within będzie pasował, bo wewnątrz niego znajduje się element z :focus.

To dokładnie ten scenariusz, którego brak przez lata wymuszał stosowanie JavaScriptu.

Dostępność – dlaczego to ważne

Z perspektywy dostępności :focus-within jest szczególnie cenny, bo:

  • pozwala wzmocnić widoczność focusu – np. podświetlając całe pole, nie tylko samą ramkę inputa,
  • ułatwia orientację przestrzenną osobom korzystającym z klawiatury: „który blok formularza mam aktualnie?”,
  • pozwala stworzyć większe „hity” wizualne bez manipulowania strukturą DOM.

Ważne: nie zastępuj :focus-visible, tylko raczej go uzupełniaj. Poniżej przykład, w którym fokus jest wyraźny głównie przy nawigacji klawiaturą:

.field input:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.field:focus-within {
background: #eff6ff;
}

Pseudoklasa :has() – brakujący selektor rodzica (i dużo więcej)

:has() to funkcjonalna pseudoklasa CSS, która pozwala wybrać element, jeśli którykolwiek z przekazanych selektorów pasuje do co najmniej jednego elementu znajdującego się w jego zakresie. Może sprawdzać potomków, rodzeństwo, a nawet elementy później w drzewie względem elementu, który wybieramy.

Definicja z bloga Chrome DevTools:

:has() reprezentuje element, jeśli którykolwiek z selektorów przekazanych jako parametry pasuje do co najmniej jednego elementu.

Składnia

Podstawowa postać wygląda tak:

element:has(selektor-relatywny) {
/* style */
}

Przykłady użycia w praktyce:

/* Ostyluj .field, jeśli ma w środku input w focusie */
.field:has(input:focus) {}

/* Ostyluj <li>, jeśli zawiera link z klasą .active */
li:has(a.active) {}

/* Ostyluj .card, jeśli zawiera obrazek */
.card:has(img) {}

selektor-relatywny w środku :has() może być złożony, korzystać z pseudoklas (:focus, :checked, :invalid) i łączyć różne warunki.

Wsparcie w przeglądarkach

Dziś sytuacja jest zupełnie inna niż na starcie specyfikacji:

  • Chromium (Chrome, Edge) wspiera :has() od wersji 105,
  • Safari wdrożyło :has() wcześnie (15.4+),
  • Firefox dołączył później, ale obecnie także posiada implementację (aktualny stan warto zawsze sprawdzić w narzędziach typu „Can I use”).

Globalne wsparcie przekracza już 90% użytkowników, co oznacza, że :has() można realnie używać w produkcji – z rozsądnymi fallbackami.

Stylowanie rodzica z :has() – praktyczne wzorce

Formularz – rodzic reagujący na focus inputa

Ten sam formularz co wcześniej, ale z użyciem :has():

<div class="field">
<label for="email">E-mail</label>
<input id="email" type="email" required>
</div>

.field {
border: 1px solid #999;
padding: 0.75rem;
border-radius: 0.5rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
/* rodzic, który ma w sobie input z focus */
.field:has(input:focus) {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}

To jest bardziej ogólne niż :focus-within, bo możemy bardzo precyzyjnie określić, który element wewnątrz musi mieć focus (np. tylko <input> albo tylko <textarea>).

Nawigacja – aktywny element w menu przy focusie linku

Przykład inspirowany wzorcem z artykułów o :has():

<ul class="nav">
<li><a href="/">Start</a></li>
<li><a href="/oferta">Oferta</a></li>
<li><a href="/kontakt">Kontakt</a></li>
</ul>

.nav li {
padding: 0.5rem 1rem;
border-radius: 9999px;
transition: background-color 0.2s;
}
.nav li:has(a:focus) {
background-color: #e5e7eb;
}

W tym układzie focus ma link <a>, natomiast ostylowany zostaje rodzic <li>, który odpowiada za całą „pigułkę” nawigacyjną – czytelniejsze i wygodniejsze wizualnie.

Karty interaktywne – hover, focus i focus-within naraz

Możemy też wykorzystać :has() do łączenia kilku stanów:

.card {
border-radius: 0.75rem;
padding: 1.5rem;
background: #fff;
transition: transform 0.15s, box-shadow 0.15s;
}
/* ostyluj kartę, gdy:
- jest hover na niej samej
- lub któryś element w środku ma focus */
.card:hover,
.card:has(:focus-visible),
.card:focus-within {
box-shadow: 0 15px 40px rgba(15, 23, 42, 0.18);
transform: translateY(-2px);
}

Z punktu widzenia dostępności:

  • użytkownicy myszy widzą efekt na :hover,
  • użytkownicy klawiatury dostają ten sam efekt dzięki :focus-visible / :focus-within,
  • :has() daje możliwość scalenia tego w elegancki warunek „cokolwiek w środku jest w focusie” bez przebudowy markup.

Pseudoklasy :focus-within vs :has(:focus) – podobieństwa i różnice

Koncepcyjnie

:focus-within odpowiada mniej więcej „ma gdzieś w środku focus”, ale działa tylko na focus i ma z góry zdefiniowaną logikę. Z kolei :has() jest ogólne: możemy napisać :has(:focus), :has(input:focus), :has(a.active), :has(:checked) i wiele innych kombinacji.

Tabela porównawcza

Poniżej zestawienie kluczowych różnic między podejściami:

Cecha :focus-within :has(:focus)
Zakres tylko focus dowolne selektory (w tym :focus)
Wyrażalność „ma focus gdzieś w środku” dowolna zależność od potomków / rodzeństwa
Czytelność bardzo wysoka przy przypadkach z focusem wymaga znajomości :has(), ale nadal dość czytelne
Wsparcie przeglądarek bardzo dobre, od lat w głównych przeglądarkach dobre w nowoczesnych (Chrome 105+, Safari 15.4+ itd.)
Wydajność relatywnie tanie potencjalnie droższe, lepiej używać rozważnie
Zastosowania formularze, menu, komponenty fokusowalne rozbudowane logiki UI, walidacja, przełączniki, layouty

Kiedy którego użyć?

Jeśli chodzi tylko o focus – zacznij od :focus-within. Jest świetnie wspierany, prosty i wystarczający do ogromnej liczby przypadków.

Jeśli potrzebujesz większej precyzji lub łączenia warunków (np. „tylko input[type="text"] z focus” lub połączenia ze stanami :hover, :checked, :invalid) – sięgnij po :has() jako progressive enhancement.

Przykład hybrydowy – najpierw :focus-within, potem ulepszamy :has():

.field:focus-within {
border-color: #2563eb;
}
/* Nowoczesne przeglądarki: focus tylko na inputach tekstowych */
@supports selector(.field:has(input[type="text"]:focus)) {
.field {
border-color: #999; /* reset */
}
.field:has(input[type="text"]:focus) {
border-color: #2563eb;
}
}

Pseudoklasa :has() poza focusem – potężne wzorce dla dostępności

Skoro mamy już selektor rodzica, warto wykorzystać go szerzej niż tylko dla focusu.

Walidacja formularzy – rodzic z błędem

HTML:

<div class="field">
<label for="email">E-mail</label>
<input id="email" type="email" required>
<p class="error" aria-live="polite">Podaj poprawny adres e-mail.</p>
</div>

CSS:

.field .error {
display: none;
}
/* rodzic, który zawiera nieprawidłowe pole */
.field:has(input:invalid) {
border-color: #b91c1c;
background: #fef2f2;
}
.field:has(input:invalid) .error {
display: block;
}

Zalety stosowania tego podejścia:

  • bez JavaScriptu reagujesz na natywny stan walidacji (:invalid, :valid),
  • komunikat błędu jest powiązany strukturalnie z polem,
  • możesz dodatkowo podpiąć komunikat pod aria-describedby dla czytników ekranu.

Przełączniki i menu rozwijane

Typowy pattern:

<button class="menu-toggle" aria-expanded="false"> Menu </button>
<nav class="menu">
...
</nav>

Możesz zmieniać styl kontenera w zależności od stanu wewnętrznego przełącznika:

.header {
transition: box-shadow 0.2s;
}
/* gdy w .header jest przycisk z aria-expanded="true" */
.header:has(.menu-toggle[aria-expanded="true"]) {
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.2);
}

Taki pattern łączy semantyczne atrybuty ARIA z czytelną logiką stylów.

Strona reagująca na stan komponentów

Możesz nawet stylować body na podstawie tego, co dzieje się głębiej w UI:

body:has(.dialog[open]) {
overflow: hidden;
}

Dzięki temu, gdy natywny <dialog> jest otwarty ([open]), blokujesz scroll w tle – bez dodatkowych klas i JS (poza samym otwieraniem dialogu).

Progressive enhancement i fallbacki

Dyrektywa @supports selector(…)

Implementacje wspierają @supports selector(...), więc możesz warunkowo użyć :has() tylko tam, gdzie jest dostępne:

/* Podstawowe style dostępne wszędzie */
.field {
border: 1px solid #999;
}
/* Ulepszenia z :has() tylko tam, gdzie jest wspierane */
@supports selector(.field:has(input:focus)) {
.field:has(input:focus) {
border-color: #2563eb;
}
}

W starszych przeglądarkach użytkownik nadal otrzyma działający formularz, tylko bez dodatkowego efektu.

Łączenie z :focus-within jako fallback

Dobry, praktyczny pattern łączący szerokie wsparcie i precyzję warunków:

/* Działa prawie wszędzie: focus-within */
.field:focus-within {
border-color: #2563eb;
}
/* W nowoczesnych: precyzyjniejsze warunki z :has() */
@supports selector(.field:has(input:focus)) {
.field {
border-color: #999;
}
.field:has(input:focus) {
border-color: #2563eb;
}
}

Wydajność i dobre praktyki

:has() jest potężne, ale to nie jest najtańszy selektor. Przy masowym stosowaniu w bardzo złożonym DOM-ie może wpłynąć na wydajność. Warto kierować się tymi zasadami:

  • zawężaj selektor przed :has() – np. .field:has(input:focus) zamiast *:has(input:focus),
  • unikaj :has() na bardzo ogólnych elementach (jak body:has(...)) w połączeniu z szerokimi selektorami w środku, jeśli nie ma takiej potrzeby,
  • używaj go do logiki UI, która naprawdę wymaga patrzenia „w dół”, a nie jako zamiennik dla prostych selektorów potomków.

:focus-within jest pod tym względem „tańsze” – ma węższy zakres i prostszą semantykę.

Wzorce komponentów przyjaznych dostępności z :focus-within i :has()

Dostępne pola formularza

HTML bazowy:

<div class="field">
<label for="name">Imię i nazwisko</label>
<input id="name" name="name" autocomplete="name" required>
<p class="hint" id="name-hint">Np. Jan Kowalski</p>
</div>

Powiązanie z ARIA:

<input id="name" aria-describedby="name-hint">

CSS:

.field {
border: 1px solid #d4d4d8;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
background: #fafafa;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
/* wzmocnienie focusu dla całego bloku */
.field:focus-within {
border-color: #2563eb;
background: #eff6ff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
/* dodatkowo: błąd walidacji */
.field:has(input:invalid) {
border-color: #b91c1c;
background: #fef2f2;
}

Efekt pracy powyższych stylów:

  • wyraźny fokus dla klawiatury,
  • czytelne oznaczenie błędów,
  • zachowanie spójne w całym formularzu.

Lista z klikalnymi kafelkami

HTML:

<ul class="cards">
<li class="card">
<a href="#">
<h2>Plan podstawowy</h2>
<p>Idealny na start.</p>
</a>
</li>
<li class="card">
<a href="#">
<h2>Plan Pro</h2>
<p>Dla wymagających.</p>
</a>
</li>
</ul>

CSS:

.card {
padding: 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 0 0 0 #111827;
transition: box-shadow 0.3s ease-out, transform 0.3s ease-out;
}
.card a {
display: block;
color: inherit;
text-decoration: none;
outline: none;
}
/* gdy link w nagłówku ma focus – podświetl kartę */
.card:has(h2 > a:focus-visible) {
box-shadow: 0 0 0 4px #111827;
transform: translateY(-2px);
}

Z punktu widzenia dostępności fokus jest bardzo wyraźny i obejmuje całą kartę, a osoba korzystająca z klawiatury dostaje takie same „pigułki” jak użytkownik myszy.

Podsumowanie praktyczne (w formie checklisty)

Bez formalnego podsumowania, ale z listą, którą możesz zastosować od razu:

  • używaj :focus-within zawsze, gdy chcesz, by rodzic reagował na focus wewnątrz,
  • sięgaj po :has(), gdy potrzebujesz bardziej złożonych warunków – focus, walidacja, przełączniki, stan komponentów,
  • projektuj z myślą o dostępności,
  • nie usuwaj całkowicie fokusów (np. outline: none) bez zapewnienia równoważnego zamiennika,
  • wzmacniaj sygnały wizualne (kontrast, obrys, tło),
  • łącz :focus-visible, :focus-within i :has(:focus) świadomie,
  • stosuj progressive enhancement z @supports selector(...), aby :has() nie psuł layoutu w starszych przeglądarkach,
  • dbaj o wydajność – zawężaj selektory przed :has() i nie nadużywaj go tam, gdzie wystarczy prostszy selektor.