Pourquoi :has() change la façon d’écrire du CSS
Pendant longtemps, CSS a été très bon pour sélectionner un élément selon son parent, ses classes, ses attributs ou sa position dans le DOM. En revanche, il manquait une pièce importante : sélectionner un élément selon ce qu’il contient.
C’est précisément ce que permet :has(). On le décrit souvent comme un « sélecteur de parent », mais c’est un raccourci un peu réducteur. En réalité, :has() sélectionne un élément seulement s’il correspond à une condition portant sur ses descendants, ses enfants directs ou même certains éléments voisins.
Avant, pour styliser un champ invalide en remontant jusqu’au bloc parent, on ajoutait souvent une classe avec JavaScript :
const field = document.querySelector('.field');
const input = field.querySelector('input');
input.addEventListener('input', () => {
field.classList.toggle('field--invalid', !input.validity.valid);
});
Avec :has(), une partie de cette logique peut revenir dans CSS, là où elle est plus déclarative et plus facile à maintenir.
.field:has(input:invalid) {
border-color: crimson;
background: color-mix(in srgb, crimson 8%, white);
}
Cette approche ne remplace pas la validation métier ni les messages d’erreur accessibles. Elle permet simplement d’éviter du JavaScript décoratif quand l’état existe déjà dans le DOM.
Comprendre la syntaxe de base
La forme générale est simple :
.card:has(img) {
display: grid;
grid-template-columns: 12rem 1fr;
}
Ici, .card est ciblé uniquement s’il contient une image. L’image n’est pas stylisée directement : c’est bien la carte qui change de mise en page.
On peut être plus précis avec le sélecteur enfant direct :
.article-preview:has(> img) {
padding-block-start: 0;
}
Ou sélectionner selon plusieurs conditions :
.product-card:has(.price):has(button) {
border: 1px solid var(--color-border-strong);
}
Cette expressivité est utile dans les design systems, où un même composant peut accepter des slots, médias, actions secondaires ou badges optionnels. Si vous structurez déjà vos composants avec des tokens cohérents, :has() complète très bien une approche comme celle présentée dans Design tokens en CSS et TypeScript.
Cas pratique : améliorer les formulaires
Les formulaires sont l’un des cas d’usage les plus naturels. Un groupe de champ peut réagir à l’état de son input, select ou textarea sans classe intermédiaire.
<form class="checkout-form">
<label class="field">
<span>Adresse e-mail</span>
<input type="email" required placeholder="nom@example.com" />
<small>Utilisée pour confirmer la commande.</small>
</label>
</form>
.field {
display: grid;
gap: 0.4rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 0.75rem;
}
.field:has(input:focus-visible) {
border-color: royalblue;
box-shadow: 0 0 0 3px color-mix(in srgb, royalblue 20%, transparent);
}
.field:has(input:invalid:not(:placeholder-shown)) {
border-color: crimson;
}
.field:has(input:valid:not(:placeholder-shown)) {
border-color: seagreen;
}
Ce code rend le groupe visuellement réactif. Le focus clavier est mis en évidence au niveau du bloc, ce qui peut améliorer la lisibilité de l’interface.
Attention toutefois : :invalid peut s’activer très tôt, parfois avant que l’utilisateur ait fini de saisir. L’ajout de :not(:placeholder-shown) limite cet effet, mais ce n’est pas une solution universelle. Pour une UX robuste, combinez ce style avec des messages d’erreur compréhensibles, comme dans Formulaires accessibles : concevoir des interfaces web vraiment utilisables.
Créer des cartes adaptatives selon leur contenu
Dans une grille d’articles, de produits ou de projets, toutes les cartes n’ont pas forcément les mêmes éléments : certaines ont une image, un badge, un lien secondaire ou une zone d’actions.
<article class="card">
<img src="cover.webp" alt="" />
<div class="card__body">
<p class="card__eyebrow">Nouveauté</p>
<h2>Tableau de bord énergétique</h2>
<p>Visualisation des consommations par bâtiment.</p>
<a href="/project/energy">Voir le projet</a>
</div>
</article>
.card {
display: grid;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
}
.card:has(> img) {
padding: 0;
overflow: clip;
}
.card:has(.card__eyebrow) .card__body {
padding-block-start: 0.5rem;
}
.card:has(a:hover) {
border-color: var(--color-accent);
}
Le dernier exemple est intéressant : la carte réagit au survol du lien qu’elle contient. Cela évite parfois d’étendre artificiellement la zone cliquable ou d’ajouter un gestionnaire d’événement JavaScript.
Il faut cependant rester prudent avec les états purement visuels. Si toute la carte semble cliquable, mais que seul le lien l’est réellement, l’interface peut devenir ambiguë. L’accessibilité et la cohérence d’interaction doivent rester prioritaires.
Remplacer certaines classes d’état
Dans beaucoup de bases de code, on trouve des classes comme .has-error, .has-icon, .is-empty, .has-actions. Certaines restent justifiées, notamment quand elles représentent un état métier. Mais quand elles ne font que refléter la présence d’un élément, :has() est souvent plus propre.
.search-box:has(button[type="submit"]) {
grid-template-columns: 1fr auto;
}
.toolbar:has(.toolbar__secondary-actions) {
justify-content: space-between;
}
.modal:has(.modal__footer) .modal__body {
padding-block-end: 1.5rem;
}
Cette logique est particulièrement utile avec des composants rendus par React, Vue, Nuxt ou Next. Le composant peut rester concentré sur sa structure, tandis que CSS adapte la présentation selon le contenu effectivement rendu.
Par exemple, en Vue :
<script setup lang="ts">
defineProps<{
title: string;
description?: string;
}>();
</script>
<template>
<section class="notice">
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
<slot />
</section>
</template>
.notice {
padding: 1rem;
border-radius: 0.75rem;
border: 1px solid var(--color-border);
}
.notice:has(p) {
padding-block-end: 1.25rem;
}
.notice:has(a, button) {
border-color: var(--color-interactive);
}
Le composant n’a pas besoin de calculer une classe supplémentaire comme notice--with-description. La structure HTML suffit.
Combiner :has() avec les architectures CSS modernes
:has() devient encore plus intéressant lorsqu’il est utilisé avec d’autres fonctionnalités CSS récentes. Avec les container queries, vous pouvez adapter un composant à la fois selon sa taille et selon son contenu. Le sujet est complémentaire à Container queries CSS : créer des composants vraiment responsives.
.card-grid {
container-type: inline-size;
}
.card:has(img) {
display: grid;
}
@container (min-width: 42rem) {
.card:has(img) {
grid-template-columns: 16rem 1fr;
}
}
Vous pouvez aussi le combiner avec les cascade layers pour éviter que des sélecteurs puissants ne deviennent incontrôlables dans une grande feuille de style. Si votre projet utilise déjà une organisation par couches comme dans CSS cascade layers : reprendre le contrôle de vos styles, placez les règles :has() dans la couche correspondant à leur rôle : composants, utilitaires ou surcharges.
@layer components {
.tabs:has([aria-selected="true"]) {
border-block-end: 1px solid var(--color-border);
}
}
Performance : ce qu’il faut éviter
Les navigateurs modernes optimisent :has(), mais cela ne veut pas dire qu’il faut l’utiliser sans discipline. Un sélecteur trop large peut rendre le style plus difficile à comprendre et potentiellement plus coûteux.
Évitez les formes globales comme :
body:has(.is-loading) * {
cursor: progress;
}
Préférez cibler une zone claire :
.app-shell:has(.loading-indicator) {
cursor: progress;
}
Quelques règles pratiques : limitez la portée du sélecteur, partez d’une classe de composant, évitez de faire dépendre toute la page d’un détail profond du DOM, et documentez les usages qui représentent une vraie décision d’architecture.
Il faut aussi éviter de transformer :has() en langage de programmation caché. Si l’état dépend d’une règle métier, d’un appel API, d’une permission ou d’une machine à états, gardez cette logique dans TypeScript ou JavaScript. Pour les états complexes, une approche comme Machines à états en TypeScript : rendre vos interfaces prévisibles reste plus appropriée.
Accessibilité et progressive enhancement
:has() améliore la couche de présentation, mais il ne doit pas masquer une structure HTML insuffisante. Un formulaire doit conserver ses labels, ses messages d’aide et ses erreurs reliées correctement. Une navigation doit rester utilisable au clavier. Une carte contenant un lien doit exposer une interaction claire.
Dans une stratégie de progressive enhancement, vous pouvez utiliser :has() pour enrichir l’expérience sans rendre la page dépendante de cette seule règle CSS. Le contenu doit rester lisible et fonctionnel même si une règle avancée ne s’applique pas. Cette logique rejoint les principes développés dans Progressive enhancement : construire des interfaces web qui ne cassent pas.
Un bon usage de :has() ne consiste donc pas à remplacer toute logique applicative. Il consiste à déplacer vers CSS les décisions qui sont vraiment visuelles : présence d’un média, focus interne, champ invalide, bouton actif, zone optionnelle, élément sélectionné.
Quand utiliser :has() en production
Utilisez :has() lorsque la structure du DOM exprime déjà l’état dont vous avez besoin. C’est idéal pour adapter un composant selon ses enfants, styliser un parent au focus, simplifier les variantes de formulaires ou réduire les classes décoratives.
Gardez JavaScript pour les états métier, les interactions complexes, les effets asynchrones, la persistance et la coordination entre composants. En pratique, :has() ne rend pas JavaScript moins important ; il évite simplement de l’utiliser pour des tâches que CSS sait maintenant faire plus directement.
Cette distinction rend les interfaces plus maintenables. Le HTML décrit la structure, CSS exprime mieux les relations visuelles, et JavaScript reste concentré sur le comportement réel. C’est souvent ce partage clair des responsabilités qui fait la différence entre une interface qui fonctionne aujourd’hui et une base frontend qui reste agréable à faire évoluer dans six mois.