Maîtrisez le sélecteur :has() en CSS : syntaxe, validation forms sans JS, layouts conditionnels, performances et fallback pour navigateurs anciens.
Pourquoi :has() change tout
Pendant 20 ans, CSS n'a jamais permis de styler un élément en fonction de ses enfants. Le rêve d'un sélecteur parent — li < a dans les anciennes spécifications jamais implémentées — restait inaccessible. Pour styler un <article> contenant une image, il fallait ajouter une classe via JavaScript : document.querySelector('article').classList.add('has-image'). Le code se multipliait, le DOM clignotait au chargement, et la maintenabilité s'effondrait dès que la structure HTML évoluait.
Depuis fin 2023, tous les navigateurs majeurs supportent nativement :has(). Cette pseudo-classe fonctionnelle prend un sélecteur en argument et teste la présence d'éléments correspondants dans le sous-arbre du candidat. C'est, en pratique, le sélecteur parent que tout intégrateur attendait.
Cas d'usage clé en une ligne
/* Styler une card qui contient une image */
.card:has(img) { padding-top: 0; }
/* Styler un label dont l'input est invalide */
label:has(input:invalid) { color: red; }
/* Styler le body si une modale est ouverte */
body:has(dialog[open]) { overflow: hidden; }
/* Styler un parent en fonction d'un état enfant — sans JS */
.product:has(.badge-promo) { border: 2px solid orange; }
:has() sans polyfill — c'est désormais une feature de production sûre.
Syntaxe et fonctionnement
La syntaxe est simple : cible:has(condition). La cible est l'élément qui sera stylé. La condition est un sélecteur relatif évalué dans le sous-arbre de la cible.
Anatomie d'un sélecteur :has()
/* Cible : article — Condition : possède au moins un h2 */
article:has(h2) {
margin-top: 2rem;
}
/* Cible : section — Condition : possède un .alert.alert-danger en descendance */
section:has(.alert.alert-danger) {
background: #fff5f5;
}
/* Cible : form — Condition : a un input requis vide */
form:has(input:required:placeholder-shown) button[type="submit"] {
opacity: 0.5;
pointer-events: none;
}
Combinateurs relatifs : descendance, frère, enfant direct
/* Descendance directe ou indirecte (par défaut) */
.menu:has(.active) { /* tout .active dans .menu */ }
/* Enfant direct uniquement avec > */
.menu:has(> .active) { /* .active uniquement enfant direct */ }
/* Frère adjacent avec + */
h2:has(+ p) { /* h2 immédiatement suivi d'un p */ }
/* Frère général avec ~ */
h2:has(~ figure) { /* h2 suivi quelque part par une figure */ }
Combinaisons multiples
/* Possède une image ET un titre */
.card:has(img):has(h3) { /* style enrichi */ }
/* Possède une image OU une vidéo */
.media:has(img, video) { /* style générique média */ }
/* Possède un .error mais PAS de .resolved */
.alert:has(.error):not(:has(.resolved)) {
background: #fee;
border-left: 4px solid red;
}
:has(), les sélecteurs sont relatifs à la cible. Le sélecteur img dans .card:has(img) signifie « une image quelque part dans .card », pas « une image globale ».
Forms : valider visuellement sans JS
Le cas d'usage le plus impactant de :has() est la validation visuelle des formulaires. Avant, chaque champ invalide demandait un listener JavaScript pour ajouter une classe sur son <label> ou son <fieldset>. Maintenant, tout est déclaratif.
Label en rouge si l'input est invalide
<label class="form-label">
Email
<input type="email" required class="form-control">
</label>
/* Label en rouge si son input est invalide ET a déjà été touché */
.form-label:has(input:invalid:not(:placeholder-shown)) {
color: #dc3545;
}
/* Label en vert si valide */
.form-label:has(input:valid:not(:placeholder-shown)) {
color: #198754;
}
Fieldset entier signalé si un champ a une erreur
<fieldset class="border p-3 rounded">
<legend class="float-none w-auto px-2">Adresse</legend>
<input type="text" name="rue" required class="form-control mb-2">
<input type="text" name="ville" required class="form-control mb-2">
<input type="text" name="cp" pattern="\d{5}" required class="form-control">
</fieldset>
/* Bordure rouge sur tout le fieldset si au moins un champ est invalide */
fieldset:has(input:invalid:not(:placeholder-shown)) {
border-color: #dc3545 !important;
box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.15);
}
/* Légende en rouge en cohérence */
fieldset:has(input:invalid:not(:placeholder-shown)) legend {
color: #dc3545;
font-weight: 700;
}
Bouton submit désactivé si formulaire incomplet
/* Désactivation visuelle automatique */
form:has(input:required:placeholder-shown, input:invalid:not(:placeholder-shown)) button[type="submit"] {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
/* Activation explicite quand tout est OK */
form:not(:has(input:invalid)):has(input:required:not(:placeholder-shown)) button[type="submit"] {
background: linear-gradient(90deg, #22c55e, #16a34a);
color: white;
}
:placeholder-shown détecte un champ resté vide. Combiné à :invalid, vous évitez de signaler une erreur tant que l'utilisateur n'a pas commencé à taper — une bonne pratique d'accessibilité.
Layouts conditionnels
:has() permet aussi d'adapter le layout d'un parent en fonction de la présence ou de l'absence d'éléments. Plus besoin de classes has-sidebar, no-image, variant-empty ajoutées en JavaScript ou Twig.
Layout adaptatif selon la présence d'une sidebar
<div class="layout">
<main>Contenu principal</main>
<aside>Sidebar optionnelle</aside>
</div>
.layout {
display: grid;
gap: 2rem;
}
/* Si pas de sidebar : contenu pleine largeur */
.layout:not(:has(aside)) {
grid-template-columns: 1fr;
}
/* Avec sidebar : 2 colonnes */
.layout:has(aside) {
grid-template-columns: 1fr 320px;
}
Card qui s'adapte à son contenu
.card:has(img:first-child) { padding-top: 0; }
.card:has(.badge-featured) { border: 3px solid gold; }
.card:has(video) { aspect-ratio: 16 / 9; }
.card:not(:has(p, .description)) { padding-block: 2rem; text-align: center; }
Galerie qui dégrade le grid si peu d'éléments
/* Galerie classique en 4 colonnes */
.gallery {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
/* Si la galerie ne contient qu'un ou deux items, centrer */
.gallery:has(:nth-child(2):last-child),
.gallery:has(:only-child) {
grid-template-columns: minmax(auto, 600px);
justify-content: center;
}
Body qui bloque le scroll quand modal/dialog ouverte
/* Plus besoin d'ajouter une classe .modal-open via JS Bootstrap */
body:has(dialog[open]),
body:has(.modal.show) {
overflow: hidden;
padding-right: var(--scrollbar-width, 0px);
}
Combinaisons avec :is(), :not(), :where()
:has() se combine élégamment avec les autres pseudo-classes fonctionnelles modernes pour produire des sélecteurs concis et puissants.
:has() + :not() — exclure des cas
/* Article qui contient un titre, mais pas de figure */
article:has(h2):not(:has(figure)) {
max-width: 720px;
}
/* Card sans aucun bouton */
.card:not(:has(button, .btn, [role="button"])) {
padding-bottom: 1.5rem;
}
:has() + :is() — multi-cibles
/* Tout titre qui suit immédiatement une figure */
:is(h2, h3, h4):has(figure + &) { /* note : & non standard ici */ }
/* Plus correct avec :is() en cible */
figure + :is(h2, h3, h4) { margin-top: 0.25em; }
/* :has() applique :is() en interne */
section:has(:is(h2, h3) + p) { /* sections où un titre est immédiatement suivi d'un paragraphe */ }
:has() + :where() — spécificité zéro
/* :where() retourne une spécificité de 0,0,0 */
/* Permet de surcharger facilement plus tard */
article:where(:has(.featured)) {
background: #fffbe5;
}
/* Override simple sans !important */
article.no-highlight {
background: transparent;
}
:has() est celle du sélecteur le plus spécifique dans son argument. div:has(.foo) a la spécificité d'une classe (0,1,0) plus celle du div (0,0,1) = (0,1,1).
Performance et bonnes pratiques
Au début, :has() faisait peur : on craignait des recalculs massifs à chaque changement de DOM. En 2026, les moteurs CSS (Blink, WebKit, Gecko) ont stabilisé les optimisations. Les benchmarks montrent un coût négligeable sur des arbres standards (< 5000 éléments par cible).
Règles d'or de performance
- ✅ Préférer
.cible:has(.classe)à:has(.classe)seul (cible plus restreinte) - ✅ Limiter la portée :
article:has(> img)est plus rapide quearticle:has(img) - ✅ Éviter les
:has()imbriqués sur 3+ niveaux - ✅ Combiner avec des classes sémantiques quand le pattern se répète
- ❌ Ne pas utiliser
:has()dans des animations sur composants à fort taux de mutation
Mesurer en pratique
/* À éviter dans une grille de 10 000 lignes — coût élevé */
table tr:has(td.error) { background: #fee; }
/* Préférer une portée explicite ou ajouter une classe */
table.with-validation tbody tr:has(td.error) { background: #fee; }
/* Mieux : ajouter la classe côté serveur ou via JS au render */
table tr.row-error { background: #fee; }
Fallback et progressive enhancement
Pour les 8% restants (anciens Firefox < 121, navigateurs legacy d'entreprise), utilisez la requête fonctionnelle @supports selector().
Détection moderne avec @supports selector()
/* Style de base pour tous */
.card {
border: 1px solid #ddd;
padding: 1rem;
}
/* Amélioration progressive si :has() est supporté */
@supports selector(:has(*)) {
.card:has(img) {
padding-top: 0;
}
.card:has(.badge-promo) {
border-color: orange;
border-width: 2px;
}
}
/* Fallback explicite avec classe (gérée par JS) */
@supports not selector(:has(*)) {
.card.has-image { padding-top: 0; }
.card.has-promo { border-color: orange; border-width: 2px; }
}
JavaScript de fallback minimal
// Fallback léger : ajoute des classes côté legacy
if (!CSS.supports('selector(:has(*))')) {
document.querySelectorAll('.card').forEach(card => {
if (card.querySelector('img')) card.classList.add('has-image');
if (card.querySelector('.badge-promo')) card.classList.add('has-promo');
});
}
Cas réels : 8 exemples production
1. Navbar qui change si dropdown ouvert
.navbar:has(.dropdown.show) {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
2. Carte produit en rupture
.product:has([data-stock="0"]) {
filter: grayscale(0.6);
position: relative;
}
.product:has([data-stock="0"])::after {
content: 'Rupture';
position: absolute;
inset: 0;
display: grid;
place-content: center;
background: rgba(255,255,255,0.7);
font-weight: 700;
}
3. Liste à puces sans puces si elle ne contient qu'un seul item
ul:has(li:only-child) {
list-style: none;
padding-left: 0;
}
4. Article qui décale son sommaire si une figure le précède
.article-toc:has(+ .article-section figure:first-child) {
margin-bottom: 3rem;
}
5. Form Bootstrap 5 — input-group rouge si invalide
.input-group:has(.form-control:invalid:not(:placeholder-shown)) {
outline: 2px solid #dc3545;
outline-offset: -1px;
border-radius: 0.375rem;
}
6. Card qui agrandit son padding si peu de contenu
.card:has(.card-body:only-child:not(:has(p))) {
padding: 3rem;
text-align: center;
}
7. Sidebar qui se replie si vide
aside:not(:has(*)) {
display: none;
}
.layout:not(:has(aside *)) main {
grid-column: 1 / -1;
}
8. Mode focus complet sur formulaire actif
/* Si un input du form est focus, atténuer le reste de la page */
body:has(form input:focus) main > *:not(:has(input:focus)) {
opacity: 0.6;
transition: opacity 0.3s ease;
}
:has() en commentaires de votre main.css. Cela évite de réinventer la même logique sur chaque projet.
Conclusion
Le sélecteur :has() est l'un des ajouts CSS les plus impactants de la décennie. Il supprime un pan entier de JavaScript dédié à la manipulation de classes pour styler des parents, simplifie radicalement la validation visuelle des formulaires, et rend les layouts conditionnels purement déclaratifs. Avec 92%+ de couverture mondiale en 2026, il est désormais une feature de production sûre.
Adoptez :has() par étape : commencez par les patterns à fort retour (validation forms, cards conditionnelles), couvrez le legacy via @supports selector(:has(*)), et documentez les patterns récurrents dans votre design system. Vos templates HTML deviennent plus simples, votre JavaScript plus mince, et votre CSS exprime enfin l'intention complète sans béquille.
:has(condition)stylise un parent en fonction de ses descendants ou voisins- Combinateurs internes : descendance,
>,+,~ - Combine parfaitement avec
:not(),:is(),:where() - Validation forms sans JS :
label:has(input:invalid) - Layouts adaptatifs :
.layout:has(aside) - Body lock :
body:has(dialog[open]) - Fallback avec
@supports selector(:has(*)) - Restreindre la cible pour la performance