Intégration web

CSS :has() : le sélecteur parent enfin natif

Css-Has Selecteur-Parent Css-Moderne Selecteurs-Css Forms-Validation Integration-Web Front-End Css3 Html5 Accessibilite Design-System Bootstrap-5 Ui-Conditionnel Progressive-Enhancement
CSS :has() : le sélecteur parent enfin natif

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; }
Compatibilité 2026 : Chrome/Edge 105+ (sept. 2022), Safari 15.4+ (mars 2022), Firefox 121+ (déc. 2023). Plus de 92% des utilisateurs mondiaux supportent :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;
}
À l'intérieur de :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;
}
UX optimale : Le sélecteur :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;
}
La spécificité de :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

Optimisations à appliquer :
  • ✅ Préférer .cible:has(.classe) à :has(.classe) seul (cible plus restreinte)
  • ✅ Limiter la portée : article:has(> img) est plus rapide que article: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;
}
Astuce DX : Sauvegardez une bibliothèque interne de patterns :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.

Récapitulatif :
  • :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

Questions fréquentes

Le sélecteur :has() est une pseudo-classe fonctionnelle qui cible un élément en fonction de ses descendants ou voisins. C'est le premier vrai sélecteur "parent" en CSS, supporté nativement par tous les navigateurs majeurs depuis fin 2023.

Chrome 105+, Safari 15.4+, Firefox 121+ et Edge 105+ supportent :has(). La couverture mondiale dépasse 92% selon caniuse. Pour les anciens navigateurs, utilisez @supports selector(:has(*)) comme fallback.

:is() simplifie l'écriture de sélecteurs multiples (ex. :is(h1,h2,h3) au lieu de h1,h2,h3). :has() teste la présence d'un élément descendant. :is() ne change pas la cible, :has() permet de cibler un parent.

:has() évite l'ajout de classes via JavaScript pour styler un parent en fonction d'un état enfant. Moins de code JS, meilleures performances, comportement déclaratif et pas de FOUC (flash of unstyled content) au chargement.

Partager