Aller au contenu

Le média des artisans du web mardi 6 octobre 2026

Design & UX

Le sélecteur CSS :has() : cibler un parent selon son contenu, enfin possible sans JavaScript

Le pseudo-sélecteur :has() permet de styler un élément en fonction de ce qu'il contient, une capacité longtemps réservée à JavaScript. Disponible nativement depuis décembre 2023, il change déjà la manière d'écrire des composants adaptatifs en CSS pur.

Illustration du sélecteur CSS :has(), dégradé bleu avec texte stylisé

Pendant près de trente ans, CSS a su cibler un élément d’après ses ancêtres, ses voisins ou ses propres attributs, mais jamais d’après son contenu. Impossible, par exemple, de styler un <section> différemment selon qu’il contient ou non une image, sans ajouter une classe en JavaScript. Le pseudo-sélecteur :has() comble ce vide : disponible nativement dans les navigateurs majeurs depuis décembre 2023, il permet de sélectionner un élément parent ou un élément précédent en fonction de ce qu’il contient ou de ce qui le suit. Il rejoint une série de fonctionnalités CSS récentes, au même titre que le positionnement d’ancrage, qui déplacent vers le langage de style des logiques longtemps réservées à JavaScript.

La syntaxe de base

:has() prend en argument une liste de sélecteurs relatifs et s’applique à l’élément si au moins un de ces sélecteurs trouve une correspondance en dessous ou à côté de lui. On peut le voir comme l’équivalent CSS d’une assertion « lookahead » en expression régulière : on sélectionne un élément d’après ce qui l’accompagne, sans jamais cibler directement cet élément accompagnant.

Cibler un élément d’après ce qui le suit

Combiné à un combinateur de fratrie, :has() permet de réagir à un élément suivant, ce que CSS ne savait pas faire jusqu’ici :

/* Réduit la marge d'un h1 immédiatement suivi d'un h2 */
h1:has(+ h2) {
  margin-bottom: 0.25rem;
}

/* Variante combinée avec :is() pour plusieurs niveaux de titres */
:is(h1, h2, h3):has(+ :is(h2, h3, h4)) {
  margin-bottom: 0.25rem;
}

C’est l’un des cas d’usage les plus cités : resserrer automatiquement l’espacement entre un titre et le sous-titre qui le suit directement, sans avoir à ajouter une classe utilitaire dans le balisage à chaque occurrence.

Formulaires et composants d’interface

Le cas d’usage le plus répandu en production concerne les formulaires et les composants interactifs, là où JavaScript servait jusqu’ici uniquement à basculer une classe d’état. Associé aux container queries, qui adaptent déjà un composant à la taille de son conteneur, :has() permet d’aller plus loin en l’adaptant aussi à son contenu :

/* Met en évidence un champ de formulaire invalide */
.form-field:has(input:invalid) {
  border-color: var(--color-danger);
}

/* Change l'apparence d'une carte produit si une case est cochée */
.product-card:has(input[type="checkbox"]:checked) {
  outline: 2px solid var(--accent);
}

/* Logique « ET » : les deux conditions doivent être vraies */
body:has(video):has(audio) {
  --has-rich-media: 1;
}

Détecter un état vide sans JavaScript

Un autre usage très courant en production consiste à afficher un message d’état vide lorsqu’une liste ne contient aucun élément, une tâche qui demandait jusqu’ici de compter les enfants côté serveur ou en JavaScript. Combiné à :not(), :has() permet d’exprimer directement cette absence :

/* Affiche un message par défaut si .list ne contient aucun .item */
.list:not(:has(.item)) .empty-state {
  display: block;
}

.list:has(.item) .empty-state {
  display: none;
}

Le même principe s’applique à un panier d’achat vide, une boîte de réception sans message ou un tableau de bord sans widget configuré : autant de cas où la condition « cet élément ne contient rien » devenait auparavant un test JavaScript exécuté après chaque mise à jour du DOM, alors qu’elle peut désormais rester déclarative et se mettre à jour automatiquement avec le contenu.

Compatibilité navigateurs

:has() fait partie des fonctionnalités dites « Baseline », c’est-à-dire disponibles sans préfixe ni activation dans l’ensemble des navigateurs majeurs depuis décembre 2023. Le support couvre aussi bien les versions desktop que mobiles des navigateurs suivants :

NavigateurVersion minimale
Chrome / Edge105 et ultérieures
Safari (desktop et iOS)15.4 et ultérieures
Firefox121 et ultérieures
Opera91 et ultérieures

Pour un projet qui doit encore supporter d’anciennes versions de ces navigateurs, un test de capacité via @supports selector(:has(a)) permet de proposer une version dégradée propre plutôt que de laisser échouer silencieusement l’ensemble d’une feuille de style.

Spécificité et limites à connaître

:has() prend la spécificité du sélecteur le plus spécifique passé en argument, exactement comme :is() et :not(). Deux limites sont à garder en tête avant de l’adopter largement : il ne peut pas être imbriqué dans un autre :has(), et les pseudo-éléments ne sont pas des sélecteurs valides à l’intérieur de ses parenthèses, ce qui évite les boucles de dépendance conditionnelle.

Piège de compatibilité le plus fréquent : si :has() n’est pas supporté par le navigateur, c’est l’intégralité du bloc de sélecteur qui échoue silencieusement, pas seulement la partie :has(). Pour une dégradation plus sûre sur du code qui doit encore tourner sur d’anciens navigateurs, l’envelopper dans :is() ou :where() limite les effets de bord, et un test de support via @supports selector(:has(a)) reste la méthode la plus explicite.

Le piège de performance à éviter

Parce que :has() oblige le moteur de rendu à examiner les descendants ou les voisins d’un élément avant de décider s’il correspond, un ancrage trop large peut déclencher des parcours de sous-arbre coûteux sur de grandes pages.

PratiqueExempleEffet sur la performance
Ancrage trop largebody:has(.sidebar)Recalcul potentiel sur l’ensemble du document
Ancrage ciblé.container:has(.sidebar-expanded)Portée limitée au conteneur concerné
Sélecteur interne large.ancestor:has(.foo)Parcours complet du sous-arbre de .ancestor
Sélecteur interne restreint.ancestor:has(> .foo)Limité aux enfants directs

La règle pratique tient en une phrase : plus l’élément ancré (la partie avant :has()) est spécifique et plus le sélecteur interne est restreint à l’aide de combinateurs directs comme > ou +, moins le navigateur a de sous-arbre à parcourir à chaque recalcul de style.

:has() déplace vers CSS une logique qui obligeait jusqu’ici à écouter des événements en JavaScript pour basculer une classe d’état.

Quand encore passer par JavaScript

:has() ne remplace pas tout. Les interactions qui dépendent d’un état applicatif, d’une donnée asynchrone ou d’une logique métier restent du ressort de JavaScript ; de la même façon, les choix de palette liés au thème clair ou sombre continuent de s’appuyer sur les design tokens plutôt que sur une sélection de contenu. En revanche, tout ce qui consistait à ajouter ou retirer une classe uniquement pour refléter l’état interne du DOM — un champ rempli, une case cochée, un enfant présent ou absent — devient un candidat naturel pour :has(), avec moins de JavaScript à maintenir et moins de risque de désynchronisation entre l’état réel du DOM et la classe censée le refléter.

Ce qu’il faut retenir

:has() est disponible nativement dans toutes les versions récentes des navigateurs majeurs depuis décembre 2023 et peut déjà remplacer une partie du JavaScript utilitaire dédié à la gestion de classes d’état. Son usage demande toutefois la même discipline que n’importe quel sélecteur combiné : ancrer sur un conteneur précis, restreindre la partie interne avec des combinateurs directs, et réserver :has() aux cas où la relation entre l’élément et son contenu est réellement structurelle.

C’est le genre de fonctionnalité CSS qui ne fait pas de bruit mais qui change discrètement une bonne partie des habitudes de composants une fois qu’on commence à l’utiliser. Sur mes derniers projets, j’ai remplacé plusieurs petits bouts de JavaScript qui ne servaient qu’à basculer une classe d’état par un simple :has(), avec un gain net en lisibilité. Le seul vrai risque, c’est l’abus de sélecteurs trop larges par facilité : ça fonctionne très bien en développement sur une page légère, et ça se voit nettement moins bien une fois la page chargée de contenu réel — Simon Janvier.

Pour aller plus loin : la documentation MDN du sélecteur :has().

Partager LinkedIn Bluesky Hacker News E-mail

À lire aussi