CSS :has() — le parent qui sait ce qui se passe à l'intérieur
CSS :has() — le parent qui sait ce qui se passe à l'intérieur
Vous avez un champ de formulaire : <div class="field"> avec une étiquette et un input à l'intérieur. L'exigence semble banale – quand l'input est invalide, tout le conteneur doit recevoir une bordure rouge et une icône d'alerte à côté de l'étiquette, pas seulement l'input lui-même. Vous essayez .field input:invalid – et vous vous heurtez à un mur. Ce sélecteur stylerait l'input lui-même, s'il voulait une bordure rouge. Mais vous voulez styler .field, c'est-à-dire le parent de cet input, en fonction de ce qui se passe à l'intérieur. Et vous découvrez soudain que le CSS – malgré des dizaines de combinateurs, de pseudo-classes et de sélecteurs d'attributs – n'a jamais, en 25 ans d'histoire, offert de moyen de faire cela.
Ce n'est pas une lacune dans votre connaissance du CSS. C'est une caractéristique fondamentale de l'architecture des sélecteurs, valable depuis CSS1 jusqu'en 2022.
Pourquoi un combinateur ne regarde toujours que dans un seul sens
Chaque combinateur en CSS – l'espace (descendant), > (enfant direct), + (frère adjacent), ~ (frère général) – décrit une relation entre deux sélecteurs, mais c'est toujours l'élément à droite du combinateur qui est stylé. .field input style l'input à l'intérieur de .field. .field ~ .error style .error, qui est un frère de .field. La direction est toujours la même : du contexte vers la cible, jamais l'inverse. Le moteur CSS, en rencontrant .field, n'a aucun mécanisme intégré pour « regarder à l'intérieur » et modifier en conséquence sa décision de style sur .field lui-même.
Pendant des années, les développeurs ont contourné cela de deux façons, toutes deux avec des coûts réels. La première – du JavaScript à l'écoute des événements input/blur, ajoutant manuellement une classe .field--invalid au parent. La seconde, plus astucieuse mais fragile – le fameux checkbox/radio hack, exploitant ~ pour styler un frère en fonction de l'état :checked, ce qui ne fonctionne que lorsque les éléments sont effectivement frères dans une structure DOM plate, et non imbriqués comme l'exige un vrai formulaire.
jsx
// Contournement d'avant :has() – ça marche, mais nécessite du JS pour// quelque chose qui est purement une conséquence visuelle de l'état du HTMLimport { useState } from "react";const FormField = ({ label, ...inputProps }) => {const [isInvalid, setIsInvalid] = useState(false);return (<div className={`field ${isInvalid ? "field--invalid" : ""}`}><label>{label}</label><input{...inputProps}onBlur={(e) => setIsInvalid(!e.target.validity.valid)}/></div>);};
:has() élimine ce code, non pas parce que c'est une astuce plus maligne, mais parce qu'il résout le problème à la racine, en étant la première pseudo-classe relationnelle de l'histoire du CSS, permettant à un élément d'interroger son propre intérieur.
Comment :has() fonctionne vraiment : un sélecteur ancré sur l'élément, pas sur le descendant
Le changement de perspective essentiel : .field:has(input:invalid) ne style pas l'input. Il style .field – exactement l'élément auquel la pseudo-classe est rattachée – à condition qu'il existe quelque part en son sein un élément correspondant au sélecteur entre parenthèses. L'élément sur lequel vous vous « ancrez » (anchor element) reste toujours le même ; :has() décide seulement s'il obtient une correspondance ou non.
css
.field:has(input:invalid) {border-color: #c62828;}.field:has(input:invalid) .field__icon {visibility: visible;}
Par défaut, le sélecteur à l'intérieur de :has() cherche n'importe quel descendant, à n'importe quelle profondeur – tout comme un simple espace. Mais vous pouvez restreindre la relation exactement comme en CSS classique, en utilisant des combinateurs directement à l'intérieur des parenthèses :
css
/* Seulement un enfant direct, pas n'importe quel descendant */.card:has(> img) {grid-template-columns: 120px 1fr;}/* Élément immédiatement suivi de .field-hint comme frère */.field:has(+ .field-hint) {margin-bottom: 4px;}
Cela fait de :has() non pas une nouvelle astuce isolée, mais une généralisation – il permet d'exprimer en CSS n'importe quelle relation qu'un combinateur pouvait déjà décrire, mais orientée « vers l'intérieur » ou « vers l'arrière », plutôt qu'uniquement « vers l'avant ».
Exemple pratique : un champ de formulaire qui sait lui-même qu'il est invalide
Assemblons cela en un composant complet et accessible. Détail important : :invalid seul se déclenche immédiatement après le chargement d'un champ vide avec un attribut required – avant même que l'utilisateur ait eu le temps de saisir quoi que ce soit, ce qui donnerait une bordure rouge sur un formulaire vide et jamais touché. :not(:placeholder-shown) résout cela, en ne correspondant au champ que lorsque l'utilisateur y a effectivement déjà laissé quelque chose.
jsx
const FormField = ({ label, id, ...inputProps }) => (<div className="field"><label htmlFor={id}>{label}</label><input id={id} {...inputProps} /><svg className="field__icon" aria-hidden="true" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 12H9v-2h2v2zm0-4H9V5h2v5z" /></svg></div>);export default FormField;
css
.field {position: relative;border: 1px solid #ccc;border-radius: 8px;padding: 8px 12px;transition: border-color 0.15s ease;}.field__icon {position: absolute;right: 12px;top: 50%;transform: translateY(-50%);width: 18px;fill: #c62828;visibility: hidden;}/* Le champ est "touché" (quelque chose a été saisi) ET invalide – réagir seulement alors */.field:has(input:not(:placeholder-shown):invalid) {border-color: #c62828;background: #fff5f5;}.field:has(input:not(:placeholder-shown):invalid) .field__icon {visibility: visible;}/* La confirmation positive compte tout autant que l'erreur */.field:has(input:not(:placeholder-shown):valid) {border-color: #2e7d32;}
Pas une seule ligne de JavaScript responsable de l'apparence. L'état de validation existe déjà nativement dans le navigateur – :valid/:invalid sont là depuis CSS3 – :has() a simplement fini par permettre de transférer cet état existant de l'input vers son conteneur, là où il était visuellement nécessaire depuis toujours.
Second motif : le composant qui sait ce qu'il contient
Ce même mécanisme résout une tout autre classe de problèmes – une mise en page dépendante de la présence d'un contenu précis, sans prop l'annonçant à l'avance.
css
/* Une carte avec image obtient une mise en page à deux colonnes, une carte sans image – le texte en pleine largeur */.card:has(> img) {display: grid;grid-template-columns: 96px 1fr;gap: 12px;}/* Le titre de la section obtient un espacement en bas seulement si la section a effectivement un sous-titre */.section:has(> .section__subtitle) .section__title {margin-bottom: 4px;}/* Une liste sans aucun élément affiche un état vide au lieu d'un espace vide */.product-list:not(:has(li)) {display: block;}.product-list:not(:has(li))::after {content: "Aucun produit ne correspond aux critères.";color: #666;}
Sans :has(), chacun de ces cas nécessiterait soit un prop (hasImage, isEmpty) réglé manuellement par le composant parent, soit une vérification de la longueur d'un tableau dans le JSX et un rendu conditionnel d'une classe séparée. :has() permet au composant de reconnaître lui-même son propre contenu et d'y réagir, plutôt que d'en être informé de l'extérieur – exactement la même direction de pensée que la carte produit consciente de son propre conteneur dans l'article sur les Container Queries : moins d'état transmis manuellement, plus de logique découlant directement de la structure.
Pièges et bonnes pratiques
- La spécificité de
:has()est celle du sélecteur le plus spécifique à l'intérieur, pas zéro..field:has(input:invalid)a la spécificité de deux classes et d'une pseudo-classe combinées – ne comptez pas sur le fait que:has()« ne compte pas » dans la spécificité, car en pratique il l'emporte régulièrement sur des règles plus simples écrites plus tard dans la feuille de style. - Un
:has()profondément imbriqué et large sans combinateur peut coûter cher..app:has(.some-deeply-nested-element)demande en théorie au moteur d'envisager tout le sous-arbre de.appà chaque changement du DOM en son sein. Les moteurs modernes (Chromium, WebKit) optimisent cela via des « invalidation sets » – ils ne recalculent pas tout aveuglément – mais l'effet dépend du sélecteur précis et de la taille de l'arbre. Restreignez la relation avec un combinateur (>, enfant direct) là où c'est possible, plutôt que de laisser une recherche en profondeur par défaut. :has()ne remplace pas:focus-within. Si vous avez seulement besoin de « le parent réagit quand un descendant a le focus »,:focus-withinexiste depuis longtemps, est moins coûteux à calculer et plus lisible –:has(:focus)donne pratiquement le même effet, mais sans raison de recourir à un outil plus général là où un outil spécialisé existe déjà.- La compatibilité des navigateurs a cessé d'être un problème.
:has()a été la dernière grande pièce du puzzle à arriver – Safari l'avait depuis la version 15.4 (mars 2022), Chrome depuis la 105 (août 2022), Firefox a rejoint le mouvement en dernier, avec la version 121 (décembre 2023). Depuis lors, il est sûr à utiliser sans@supportsdans tout nouveau projet.
Conclusion
Pendant 25 ans, le CSS n'a permis de décrire que des relations orientées dans un seul sens – du contexte vers la cible, du parent vers l'enfant, du précédent vers le suivant. :has() n'est pas une nouvelle variante de la même idée, mais la première pseudo-classe permettant à un élément d'interroger son propre intérieur et de réagir à ce qu'il y trouve – une erreur de validation dans un champ, la présence d'une image dans une carte, l'absence d'éléments dans une liste. Cela déplace toute une classe de décisions qui devaient auparavant atterrir dans le JavaScript ou dans des props transmis manuellement depuis le haut, vers l'endroit où elles appartiennent : la structure HTML et les règles CSS qui décrivent cette structure.