Per anni, ogni frontend developer ha scritto una variante di questo codice:
document.querySelectorAll('.card').forEach(card => {
if (card.querySelector('.error')) {
card.classList.add('has-error');
}
});
Non perché fosse elegante. Non perché fosse la soluzione giusta. Ma perché CSS non aveva alternativa.
Poi è arrivato :has(). E in un progetto reale ho eliminato circa 300 righe di JavaScript dedicate esclusivamente a questo tipo di manipolazione del DOM.
:has()permette di applicare stili a un elemento in base al contenuto che contiene.
In molti casi sostituisce JavaScript che aggiungeva e rimuoveva classi ai parent element.
Supportato da tutti i browser moderni da dicembre 2023.
Il problema storico: CSS non sapeva guardare verso l’alto
| Anno | Evento |
|---|---|
| 1996 | CSS 1 — selettori di base |
| 1998 | CSS 2 — selettori discendenti e figli |
| 2009 | CSS 3 — pseudo-classi, attributi, combinatori avanzati |
| 2022 | :has() — la soluzione CSS al problema del parent selector |
26 anni. Tanti ne sono serviti per avere un selettore che guardasse verso l’alto nell’albero del DOM.
CSS ha sempre saputo selezionare verso il basso nell’albero del DOM.
.parent .child seleziona un figlio dentro un genitore..parent > .child seleziona un figlio diretto.
Ma CSS non aveva mai potuto esprimere il concetto opposto:
“Se dentro questo elemento esiste un discendente con questa caratteristica, modifica l’elemento genitore.”
Questa limitazione era talmente nota che per anni si è cercato un “parent selector” nativo. Proposte, discussioni, RFC nei repository dei browser vendor. Niente di concreto per oltre due decenni.
La conseguenza diretta era una classe intera di JavaScript che non faceva logica applicativa, non chiamava API, non gestiva stato complesso. Faceva soltanto una cosa: osservava il DOM e aggiungeva o rimuoveva classi sui genitori in risposta a cambiamenti nei figli.
Codice necessario, ma ingrato. Difficile da mantenere. Fonte di bug sottili quando le condizioni cambiavano.
Arriva :has(): il selettore relazionale
:has() è una pseudo-classe CSS che seleziona un elemento in base alla presenza di uno o più discendenti che corrispondono al selettore passato come argomento.
La sintassi è immediata:
.card:has(.error) {
border: 2px solid red;
}
Traduzione diretta: “applica questo stile a .card se al suo interno esiste un elemento con classe .error.”
Niente JavaScript. Niente event listener. Niente querySelector + classList.
In pratica: guardi dentro un elemento e decidi cosa fare con il suo genitore.
Quattro casi pratici che sostituiscono JavaScript reale
1. Form con errori di validazione
Questo è il pattern più comune che ho eliminato. L’obiettivo era evidenziare l’intero gruppo di un campo quando al suo interno compariva un messaggio di errore.
<!-- javascript -->
const forms = document.querySelectorAll('.form-group');
forms.forEach(group => {
if (group.querySelector('.error-message')) {
group.classList.add('has-error');
}
});
Il problema reale: questo codice veniva eseguito una volta, all’inizializzazione. Per intercettare errori dinamici serviva un MutationObserver o si rieseguiva la funzione dopo ogni validazione. La complessità cresceva.
CSS
.form-group:has(.error-message) {
border-color: #e53e3e;
background-color: #fff5f5;
}
E funziona anche se il messaggio di errore appare dopo un secondo, dopo un’ora, dopo una chiamata API. CSS non ha bisogno di sapere quando.
2. Card selezionabili con checkbox
Un pattern classico per liste di elementi selezionabili: la card intera deve cambiare aspetto quando la checkbox al suo interno viene spuntata.
<div class="card">
<input type="checkbox" name="item" value="1">
<p>Elemento della lista</p>
</div>
Prima in javascript:
document.querySelectorAll('.card input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', () => {
checkbox.closest('.card').classList.toggle('is-selected', checkbox.checked);
});
});
Dopo in CSS:
.card:has(input[type="checkbox"]:checked) {
background-color: #ebf8ff;
border-color: #3182ce;
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
}
Zero JavaScript. Zero event listener. La card lo sa da sola.
3. Menu con sottomenu aperto
.menu-item:has(.submenu.open) {
font-weight: 600;
color: var(--color-primary);
}
Prima serviva JavaScript che portasse la notizia al genitore. Ora non serve intermediario.
4. Label e wrapper che reagiscono alla validazione del campo
.field-wrapper:has(input:valid) {
border-color: #38a169;
}
.field-wrapper:has(input:invalid):has(input:not(:placeholder-shown)) {
border-color: #e53e3e;
}
Il secondo esempio combina due condizioni: mostra l’errore solo se il campo è invalido e non è vuoto. L’utente ha scritto qualcosa, quindi ha senso segnalarglielo.
Uso avanzato: combinare :has() con altri selettori
`:has()` non lavora solo con classi. Funziona con classi, attributi, pseudo-classi e combinatori CSS.
Selezione basata su fratelli (sibling selector):
/* Rimuovi il margine da h1 se è immediatamente seguito da un paragrafo */
h1:has(+ p) {
margin-bottom: 0;
}
Questo risolve un problema tipografico classico: la doppia spaziatura tra titolo e primo paragrafo. Prima si risolveva con classi aggiuntive o JavaScript. Ora è un selettore CSS.
Condizioni combinate:
/* Evidenzia la sezione solo se contiene sia un'immagine che un titolo */
.section:has(img):has(h2) {
padding: 2rem;
background: var(--surface-elevated);
}
Esempi che fino a ieri avresti scritto in JavaScript
Questa sezione è per chi pensa che :has() sia utile ma non rivoluzionario. Questi tre esempi cambiano spesso idea.
Bloccare lo scroll quando un dialog è aperto
body:has(dialog[open]) {
overflow: hidden;
}
Quando un <dialog> nativo viene aperto, il body smette di scrollare. Senza JavaScript. Senza event listener su open e close. Il CSS osserva la presenza dell’attributo [open] e reagisce direttamente.
Questo era uno degli snippet JavaScript più copia-incollati del web:
dialog.addEventListener('open', () => document.body.style.overflow = 'hidden');
dialog.addEventListener('close', () => document.body.style.overflow = '');
Non serve più.
Card che cambiano layout in base al contenuto
/* Card senza immagine: padding normale su tutti i lati */
.card {
padding: 1.5rem;
}
/* Card con immagine: azzera il padding in alto per far sì che l'immagine tocchi i bordi superiori della card */
.card:has(img) {
padding-top: 0;
}
Il layout si adatta al contenuto senza classi aggiuntive nel markup. Niente .card--with-image. Niente JavaScript che inspecta il DOM dopo il rendering. Il selettore fa tutto.
HTMX: stili che reagiscono al contenuto caricato dinamicamente
Questo esempio parla direttamente a chi lavora con PHP, CodeIgniter e HTMX.
Scenario tipico: un campo di ricerca che carica risultati via hx-get. Quando i risultati arrivano, voglio che il wrapper cambi aspetto.
** HTML **
<div class="search-wrapper">
<input type="text" hx-get="/search" hx-target=".results">
<div class="results">
<!-- HTMX inserisce qui i risultati -->
</div>
</div>
** CSS **
/* Il wrapper reagisce quando .results contiene almeno un figlio diretto */
.search-wrapper:has(.results > *) {
border-color: #38a169;
box-shadow: 0 0 0 2px rgba(56, 161, 105, 0.2);
}
Quando HTMX carica i risultati e li inserisce nel DOM, il wrapper cambia aspetto automaticamente. Nessun JavaScript aggiuntivo. Nessuna classe iniettata. Nessun evento custom. Solo CSS che osserva il DOM che HTMX ha già modificato.
Il backend developer che lavora con HTMX ha già delegato la logica al server. :has() completa il quadro: anche la risposta visuale lato client diventa dichiarativa.
Attenzione alle performance
Questo punto è rilevante in produzione, ma va inquadrato correttamente.
I browser moderni hanno ottimizzato pesantemente :has(). Nella maggior parte dei casi d’uso reali le performance sono del tutto accettabili. Il problema emerge con selettori troppo generici, che costringono il browser a rivalutare l’intera struttura del documento ad ogni mutazione del DOM.
Regola pratica: ancorare :has() a elementi specifici, non a contenitori di alto livello.
** CSS **
/* Da evitare */
body:has(.sidebar-open) { ... }
/* Preferibile */
.layout:has(.sidebar-open) { ... }
Nel secondo caso, il browser rivaluta solo i discendenti di .layout, non dell’intero documento.
Supporto browser: dove siamo oggi
Da dicembre 2023, :has() funziona su tutti i dispositivi e versioni browser recenti. Chrome, Firefox, Safari ed Edge supportano tutti il selettore.
Secondo i dati di Can I Use, la copertura globale degli utenti supera il 93%. Prima di pubblicare, verifica il dato aggiornato su caniuse.com/css-has: il numero tende a crescere mese su mese.
Per progetti con requisiti di compatibilità con browser molto vecchi o Internet Explorer, il supporto non è garantito. In quei contesti, la soluzione JavaScript rimane necessaria.
Per verificare la compatibilità specifica del proprio contesto, la fonte primaria è MDN Web Docs e Can I Use.
Cosa :has() non sostituisce
Chiarisco questo punto perché la narrativa “JavaScript che sparisce grazie a CSS” può essere fraintesa.
:has() lavora esclusivamente sullo stato visuale del DOM. Non sostituisce JavaScript per:
- Chiamate API e fetch di dati. CSS non comunica con server.
- Logica applicativa complessa. Condizioni, cicli, trasformazioni di dati.
- Gestione di eventi applicativi. Click handler con side effect, routing, form submission.
- Componenti dinamici con stato. Tutto ciò che richiede
useState, store reattivi, o simili.
La distinzione è netta: JavaScript che modificava classi sui genitori solo per riflettere uno stato visivo già presente nel DOM è rimpiazzabile. JavaScript che elabora dati, gestisce effetti collaterali o coordina componenti non lo è.
Conclusione
Per oltre vent’anni abbiamo scritto JavaScript per compensare una mancanza del CSS.
Oggi quella mancanza non esiste più.
La domanda non è se usare :has(). La domanda è quante righe di JavaScript stai ancora mantenendo senza motivo.
Riferimenti
- MDN Web Docs: CSS :has() pseudo-class — specifica, esempi, note sulle performance
- Can I Use: CSS :has() relational pseudo-class — tabelle di compatibilità browser aggiornate
- CSS Selectors Level 4 Specification — specifica W3C