Stylisez les éléments parents en fonction de leur contenu à l’aide de CSS :has()
Découvrez comment :has() permet enfin aux sélecteurs d’examiner les descendants et les frères/sœurs, où les coquilles de microfrontends peuvent l’utiliser, et pourquoi le Shadow DOM associé aux contrats de nom de classe restreint encore ce modèle.
Où les sélecteurs CSS de direction n’ont jamais été
Pendant des décennies, un sélecteur CSS pouvait décrire les ancêtres et les descendants, ainsi que les éléments frères adjacents, mais il ne pouvait pas styliser un élément en fonction de son contenu ou de ce qui le suivait dans la structure. Écrire .card img permet de chercher une image à l’intérieur d’une carte. Écrire h2 + p permet de passer à l’élément suivant, un paragraphe, à partir d’un titre. Les formes inverses n’étaient tout simplement pas disponibles : rien ne permettait de styliser une carte en fonction de médias imbriqués, et rien ne permettait de styliser un titre en fonction d’un paragraphe qui apparaissait plus tard dans la structure.
Cette asymétrie n’était pas le fruit du hasard lié aux préférences des API. Historiquement, les moteurs d’application de styles parcouraient le document principalement de haut en bas, dans un sens unique. Une règle qui aurait dû examiner des éléments futurs ou des descendants profonds avant de déterminer si son sujet correspondait aurait contredit ce modèle de traitement en une seule passe. Pendant environ vingt-cinq ans, tous les moteurs de sélecteurs majeurs ont respecté cette contrainte, et toutes les équipes ayant besoin de styliser des conteneurs en fonction de leur contenu ont trouvé des solutions détournées plutôt que d’utiliser directement cette capacité : une classe activée via JavaScript, un nœud d’encapsulation supplémentaire, ou une propriété booléenne transmise à travers plusieurs composants simplement pour indiquer « cette instance possède un pied de page ».
:has() élimine cette solution détournée en permettant à la règle elle-même d’examiner les éléments que le CSS refusait auparavant d’analyser. Une fois cette porte ouverte, la question intéressante est de savoir ce qu’un moteur de sélecteurs peut réellement examiner.
Le stylage relationnel modifie également la manière dont les systèmes de conception documentent les variantes. Au lieu d’exiger que les auteurs se souviennent d’une classe modificatrice chaque fois qu’une carte contient des médias, le feuille de style peut déduire la variante à partir de la structure déjà présente dans le markup. Cela est pratique, et c’est aussi pourquoi des modifications accidentelles de la structure entraînent des régressions visuelles.
Que vérifie réellement :has() ?
Une pseudo-classe est une condition préfixée d’un deux-points attachée à un sélecteur. Parmi les exemples courants, on trouve :hover et :first-child : ils correspondent en fonction de leur état ou de leur position structurelle, et non en raison du nom d’une balise, d’une classe ou d’un id. :has() appartient à la même famille, mais son argument est un autre sélecteur. Il vérifie si l’élément principal contient — ou est d’une autre manière lié à — quelque chose qui correspond à cet argument.
.card:has(img) {
border: 2px solid royalblue;
}
La règle peut être lue presque littéralement : appliquer cette bordure à .card uniquement lorsque l’élément img correspondant existe dans la relation que évalue :has(). Le sélecteur avant :has() désigne l’élément qui reçoit les déclarations. Le sélecteur à l’intérieur des parenthèses ne désigne que la condition. Lorsque cette condition est fausse, :has() ne correspond à rien et la règle n’est jamais appliquée.
Un fait dissipe la plupart des confusions liées à une première utilisation : les déclarations s’attachent au sujet avant :has(), tandis que le sélecteur entre parenthèses n’est qu’un prédicat. La mauvaise interprétation courante est « sélectionner l’image, puis styliser sa carte ». En réalité, le mécanisme fonctionne à l’inverse : le navigateur évalue d’abord la condition, puis décide s’il faut afficher le sujet externe. cet ordre d’évaluation est important lors de l’enseignement de cette fonctionnalité, car les démonstrations qui mettent en avant le critère interne amènent les utilisateurs à appliquer des styles au mauvais nœud.
L’expression « quelque part à l’intérieur » cache encore des détails importants. Jusqu’où :has() cherche-t-il, et quels types de relations sont pris en compte ?
Descendant, Enfant et Frère/Sœur : trois niveaux d’accès différents
L’argument passé à :has() n’est pas limité aux « descendants ». Il s’agit d’un sélecteur complet. Les combinateurs — les symboles qui définissent les relations entre les parties d’un sélecteur — modifient ce que la pseudo-classe vérifie.
/* descendant: image anywhere inside, at any depth */
.card:has(img) { }
/* direct child only: image must be an immediate child of .card */
.card:has(> img) { }
/* next sibling: heading immediately followed by a paragraph */
h2:has(+ p) { }
/* any later sibling: some paragraph follows, not necessarily right after */
h2:has(~ p) { }
Chacune de ces formes examine une partie différente de l’arbre DOM. Le choix du mauvais sélecteur entraîne un échec silencieux : rien n’est levé en erreur. Un .card:has(img) écrit lorsque l’auteur voulait en réalité .card:has(> img) peut sembler correct dans un environnement simplifié, mais correspondre plus tard à une carte dont l’image se trouve à trois niveaux de conteneurs de profondeur, suite à un redessin ajoutant un autre conteneur.
Fondamentalement, le vocabulaire est limité : une vérification de descendance à n’importe quelle profondeur, une restriction aux enfants directs, ainsi que des vérifications de frères/sœurs pour le frère/sœur immédiatement suivant ou tout frère/sœur ultérieur. Les effets de survol en cascade, le stylage de validation des formulaires, la détection d’état vide et des schémas similaires sont des combinaisons de ces quatre mécanismes. Rien d’autre de plus complexe ne se trouve en dessous d’eux.
La question suivante est de savoir où ces mécanismes apportent réellement de la valeur dans une base de code en production, plutôt que dans une démonstration de deux lignes.
Un shell qui réagit à ce qu’il ne peut pas afficher lui-même
Imaginez une application shell qui gère le layout de la page et intégre d’autres applications en son sein : une barre de navigation, un widget listant les produits et un panneau de notifications. Ces éléments sont développés et déployés indépendamment. Cette composition à l’exécution est généralement appelée architecture microfrontend. L’avantage réside dans le fait que l’équipe chargée des notifications peut publier ses mises à jour sans devoir coordonner leur déploiement avec l’équipe de la shell.
Cet indépendance a un coût que la shell doit assumer. La shell ne peut pas savoir à l’avance ce que le panneau de notifications affichera, car les deux sont déployés selon des calendriers distincts. Si la shell souhaite afficher le contenu principal dès que le panneau a quelque chose à montrer, les solutions traditionnelles consistaient soit en un événement partagé déclenché lors du chargement du contenu, soit en un stockage d’état partagé consulté par les deux parties. Chacune de ces solutions crée une couplage entre des répertoires qui avaient été conçus justement pour en réduire le niveau.
/* shell/layout.css */
.main-content:has(~ .notification-panel:not(:empty)) {
margin-right: 320px;
transition: margin-right 0.2s ease;
}
<!-- shell/index.html -->
<div class="app-shell">
<main class="main-content">...</main>
<div class="notification-panel" id="notification-slot"></div>
</div>
// notification-panel/mount.js
import { renderAlert } from './alert.js';
export function mount(targetId, alertData) {
const slot = document.getElementById(targetId);
slot.appendChild(renderAlert(alertData));
}
Utiliser :has() ici signifie que le marges ne changent que lorsque le panneau de notification contient un contenu réel, et non au moment où apparaît un nœud hôte vide. Remarquez ce que ces feuilles de style omettent : pas de bus d’événements, pas de stockage partagé, et pas non plus de mécanisme d’échange que l’une ou l’autre équipe aurait dû implémenter pour l’autre. Deux équipes qui n’ont jamais lu le code de l’autre peuvent néanmoins coordonner un changement de mise en page, car elles interagissent toutes deux avec le même DOM dynamique.
Cette absence de coordination explicite est précisément ce qui rend les limites suivantes dignes d’être mentionnées.
Où s’arrête la portée, et ce qu’elle suppose sans le dire
Deux contraintes deviennent plus importantes lorsque la logique de mise en page concerne des fragments distribués de manière indépendante.
L’isolation technique passe en premier. Les fragments enfermés dans le Shadow DOM maintiennent un arbre opaque. Les styles externes ne peuvent pas interroger cet arbre, de sorte qu’un sélecteur externe dépendant du vide à l’intérieur ne s’active jamais. Un DOM lumineux partagé est une condition préalable ; l’encapsulation par défaut des composants Web désactive silencieusement ce schéma.
La deuxième contrainte est opérationnelle. Les règles de mise en page qui se réfèrent à .notification-panel partent du principe que le nom de la classe et le modèle de conteneur restent stables. Si vous renommez la classe, déplacez le contenu dans un portail ou l’affichez ailleurs, l’élément externe cesse de réagir sans erreur de type ni test unitaire échoué. Le débogage consiste alors à redécouvrir un contrat CSS implicite géré par un autre répertoire.
Par rapport à des événements explicites ou à un stock partagé, cela n’est pas automatiquement pire. Le couplage existe toujours ; il se trouve simplement dans le texte des sélecteurs plutôt que dans un symbole importé.
Réassembler les éléments
Les moteurs de sélection historiques étaient optimisés pour un traitement en avant, c’est pourquoi les requêtes de type conteneur de cette nature relationnelle sont apparues tardivement. :has() permet enfin à un élément principal de réagir aux descendants, enfants et frères/sœurs suivants. Ces trois types de relations suffisent à expliquer le changement dans la structure des shells — aucune pseudo-langue supplémentaire n’est nécessaire.
Cette même capacité explique les modes d’échec. Tenter d’accéder à des arbres d’ombre échoue de manière fatale. Les tentatives basées sur des noms de classes étrangères échouent silencieusement. Les équipes compétentes considèrent les sélecteurs relationnels comme des outils de présentation liés à un accord DOM documenté, et non comme un substitut aux contrats de service délibérés entre les responsables des microfrontends.