Vous livrez une interface. Une semaine plus tard, quelqu’un ajoute une icône d’aide dans l’encapsuleur d’un champ, et soudainement la bordure rouge “invalide” n’apparaît plus.
Ou le chef produit veut une grille de cartes qui met en avant toute carte contenant un badge « Deal » — sans câbler dix observateurs JS différents.
Le coupable tranquille est souvent le même : nous demandons au CSS de styliser vers le haut de l’arbre DOM, et historiquement le CSS refusait. Maintenant il ne le fait plus.
:has() est le sélecteur parent que nous voulions depuis vingt ans, et il est enfin utilisable en production — si vous l’abordez comme un problème d’exploitation, pas comme un tour de démonstration.
Ce qu’est réellement :has() (et pourquoi c’est différent)
:has() est une pseudo-classe relationnelle : elle matche un élément s’il contient quelque chose qui matche la liste de sélecteurs que vous mettez à l’intérieur.
La traduction pratique est simple : vous pouvez enfin styliser un parent en fonction de l’état d’un enfant.
Exemple : mettre en évidence un wrapper de champ s’il contient un input invalide.
cr0x@server:~$ cat ui.css
.field:has(input:invalid) {
border: 2px solid #c1121f;
background: #fff5f5;
}
Ça se lit comme de l’anglais. Ce qui est la raison principale pour laquelle c’est dangereux : cela rend les choses difficiles faciles.
:has() change votre façon de penser la structure DOM, les frontières de composants et la propagation d’état.
Si vous l’utilisez bien, vous supprimez du JS. Si vous l’utilisez paresseusement, vous créez des tickets « pourquoi toute la page se redessine ? ».
Ce que ce n’est pas
- Ce n’est pas un remplacement pour un HTML bien fait. Si votre DOM est un tiroir à bazar,
:has()vous aide juste à trouver le bazar plus vite. - Ce n’est pas une excuse pour arrêter d’utiliser des classes. Votre futur vous veut des hooks stables pour les tests et les refactorings.
- Ce n’est pas magique. C’est toujours du matching de sélecteurs, toujours affecté par l’invalidation et le recalcul des styles.
Une formulation opérationnelle utile : traitez :has() comme l’ajout d’une nouvelle arête de dépendance dans votre graphe d’état.
Quand un enfant change, le style du parent peut nécessiter un recalcul. C’est tout le point. C’est aussi le coût.
Une idée paraphrasée souvent attribuée aux cercles de fiabilité : « Optimisez d’abord pour la débogabilité ; l’optimisation de performance est plus facile que de raisonner sur une boîte noire. »
C’est l’attitude à adopter avec :has(). Utilisez-le pour réduire l’état opaque en JS, mais gardez les sélecteurs lisibles et bornés.
Faits et contexte intéressants à répéter en revue design
Vous allez devoir défendre :has() en revue de code, et vous aurez les questions classiques : « C’est supporté ? » « C’est lent ? »
« Pourquoi ne pas juste ajouter une classe ? » Voici les éléments utiles de contexte.
- Le CSS a obtenu
:has()via Selectors Level 4. La demande pour un “sélecteur parent” est ancienne ; cela a pris du temps car cela touche à la performance et à l’invalidation. - jQuery avait un sélecteur
:has()bien avant le CSS. C’est en partie pourquoi les ingénieurs supposaient que le CSS natif ferait la même chose. Il ne le fait pas. - Les navigateurs ont résisté pendant des années parce que c’est une “dépendance inverse”. Traditionnellement, les dépendances de style allaient du parent vers l’enfant ;
:has()peut inverser ça. - Safari l’a livré tôt. Cela a surpris ceux qui pensent encore que Safari est en retard perpétuel. Ce n’est pas le cas — du moins pour ça.
- Les moteurs ont dû améliorer le suivi d’invalidation. Savoir efficacement quels ancêtres peuvent matcher un sélecteur quand un nœud change est du vrai engineering, pas un haussement d’épaules.
:has()permet le “style d’état” sans DOM supplémentaire. Avant, les équipes ajoutaient souvent des éléments wrapper seulement pour des hooks de style ; c’était essentiellement de la dette technique en HTML.- Il se marie bien avec les pseudo-classes modernes de formulaire.
:user-invalid,:invalid,:required,:placeholder-shownet:focus-withindeviennent plus utiles quand on peut remonter leur effet. - Il s’accorde avec l’état ARIA. Cibler
[aria-expanded="true"]ou[aria-invalid="true"]à l’intérieur de:has()correspond bien aux états accessibles de l’UI.
Blague n°1 : Les gens appellent :has() le « sélecteur parent », ce qui est exact — comme appeler un centre de données une « pièce avec des ordinateurs ».
Formulaires : validation, champs requis et wrappers d’erreur « qui fonctionnent tout seuls »
Les véritables UI de formulaire ne se résument pas à un seul input et un bouton submit. Ce sont des wrappers, icônes, labels, textes d’aide, erreurs serveur, contraintes client,
et le moment où quelqu’un ajoute un bouton « œil » inline dans un champ mot de passe.
Le pattern stable est : styliser le wrapper, pas l’input. Mais le wrapper doit réagir à l’état de l’input. C’est le territoire de :has().
État invalide au niveau du wrapper
cr0x@server:~$ cat form.css
.field {
border: 1px solid #ccd5e1;
border-radius: 10px;
padding: 10px;
display: grid;
gap: 6px;
}
.field:has(input:invalid) {
border-color: #c1121f;
background: #fff5f5;
}
.field:has(input:invalid) .hint {
color: #c1121f;
}
.field:has(input:focus-visible) {
box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.2);
}
Cela évite le piège courant : ne styliser que l’input alors que la zone cliquable réelle est le wrapper.
Désormais votre anneau d’erreur inclut le bouton d’icône, la flèche du select, le label préfixe — tout.
Étiquetage des champs requis sans classes supplémentaires
Vous pouvez marquer les champs requis en fonction de la présence d’enfants :required.
cr0x@server:~$ cat required.css
.field label::after { content: ""; }
.field:has(:required) label::after {
content: " *";
color: #c1121f;
}
C’est là que vous devez faire preuve de discipline : si votre wrapper peut contenir plusieurs inputs (par ex. une plage de dates),
décidez si « requis » doit refléter n’importe quel input requis ou tous les inputs requis. Puis encodez-le.
Sinon vous livrez un spam incohérent d’étoiles.
Erreurs serveur et stylisation pilotée par ARIA
La validité côté client n’est pas toute l’histoire. En production, le serveur rejette des choses : contraintes d’unicité, règles métier,
« ce code promo date de 2019, arrêtez ». Ces états reviennent souvent sous forme d’attributs ARIA dans votre HTML rendu.
cr0x@server:~$ cat aria.css
.field:has([aria-invalid="true"]) {
border-color: #c1121f;
}
.field:has([aria-invalid="true"]) .hint {
color: #c1121f;
font-weight: 600;
}
C’est aussi un bon endroit pour définir une politique d’équipe : privilégiez l’état ARIA aux attributs « data-error » personnalisés
quand ils décrivent la même chose. Cela maintient accessibilité et stylisation alignées.
UI d’aide conditionnelle : montrer l’avertissement « caps lock activé » seulement quand il existe
Vous pouvez allouer de l’espace de manière conditionnelle et éviter les sauts de mise en page en stylisant selon la présence d’un élément d’aide.
Pas de JS requis. Aussi : pas de placeholders vides.
cr0x@server:~$ cat helper.css
.field .caps-warning { display: none; }
.field:has(.caps-warning[data-visible="true"]) .caps-warning {
display: block;
color: #8a6d3b;
}
Si vous le faites correctement, le JS ne fait que définir data-visible sur l’avertissement lui-même.
Le wrapper réagit, et vous ne propagez pas des classes parentales à travers la moitié de votre arbre de composants.
Cartes : styles dépendant du contenu sans colle JS
Les cartes sont l’endroit où les équipes UI vont mourir lentement. Le marketing veut des badges, l’éditorial veut un sous-titre,
le produit veut la carte entière cliquable mais a aussi une icône de favoris qui ne doit pas naviguer.
Puis vous ajoutez des A/B tests qui insèrent des labels aléatoires. Parfait.
:has() vous permet de styliser la carte en fonction de ce qu’elle contient — sans forcer le système de templates à injecter des classes de style partout.
La bonne approche est d’utiliser :has() pour des décisions internes au composant. Si l’état vient de l’extérieur du composant,
vous voulez toujours des classes/props explicites.
Mettre en avant les cartes contenant un badge « Deal »
cr0x@server:~$ cat cards.css
.card {
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 14px;
background: white;
}
.card:has(.badge--deal) {
border-color: #f59e0b;
box-shadow: 0 8px 26px rgba(245, 158, 11, 0.18);
}
.card:has(.badge--deal) .title {
color: #92400e;
}
Vous n’avez pas ajouté une classe .card--deal. Vous n’avez pas changé le backend. Vous n’avez pas écrit de script « scanner le DOM pour les badges ».
Vous avez simplement stylisé un composant réel en fonction d’un contenu réel.
Cartes avec actions : changer le padding quand la rangée d’actions existe
Problème classique : certaines cartes ont un footer avec des boutons ; d’autres non. Le padding devient maladroit.
Les équipes résolvaient cela avec des templates en double ou des props « hasFooter ». Maintenant c’est juste du CSS.
cr0x@server:~$ cat card-footer.css
.card { padding-bottom: 14px; }
.card:has(.card__actions) {
padding-bottom: 10px;
}
.card:has(.card__actions) .card__actions {
margin-top: 12px;
border-top: 1px solid #eef2f7;
padding-top: 10px;
}
Notez la portée : nous ne cherchons que .card__actions à l’intérieur de .card.
Cette mise en portée est la première décision « performance » que vous prenez.
Cartes cliquables sans liens imbriqués cassés
Beaucoup d’équipes enveloppent la carte entière dans une balise anchor. Ensuite elles embarquent d’autres anchors, et vous obtenez du HTML invalide ou des comportements de clic étranges.
Le meilleur pattern : garder un lien principal à l’intérieur, et styliser la carte quand elle contient ce lien.
cr0x@server:~$ cat clickable-card.css
.card:has(a.card__primary-link:hover) {
transform: translateY(-1px);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.card:has(a.card__primary-link:focus-visible) {
outline: 3px solid rgba(0, 120, 212, 0.35);
outline-offset: 3px;
}
Cela donne l’effet « toute la carte semble interactive » sans commettre de crimes HTML.
Le lien reste la cible sémantique. La carte réagit visuellement.
Filtres et recherche facettée : bascules, compteurs et panneaux d’état
Les filtres, c’est la réalité en production : beaucoup de checkboxes, bascules, pilules, et logique « Réinitialiser tout ».
L’état UI a tendance à s’étaler : la barre latérale doit savoir si quelque chose à l’intérieur est coché ; chaque groupe de filtres a besoin d’un indicateur “sale” ;
le bouton d’appliquer ne devrait être actif que lorsqu’il y a des changements ; et la ligne de synthèse doit afficher des comptes.
:has() ne calculera pas des comptes (CSS n’est pas un moteur de requête), mais il peut résoudre les questions binaires qui pilotent beaucoup de polish UI :
« quelque chose est-il sélectionné ? », « ce groupe est-il actif ? », « ce groupe contient-il un input invalide ? », « le panneau est-il étendu ? »
Marquer les groupes de filtres actifs si une checkbox est cochée
cr0x@server:~$ cat filters.css
.filter-group {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 10px;
}
.filter-group:has(input[type="checkbox"]:checked) {
border-color: #2563eb;
background: #eff6ff;
}
.filter-group:has(input[type="checkbox"]:checked) .filter-group__title::after {
content: " (active)";
font-weight: 600;
color: #2563eb;
}
Vous venez d’éliminer toute une catégorie de JS : itérer les groupes, basculer des classes, gérer des mutations DOM.
Le balisage pilote l’état. C’est la bonne direction.
Activer le bouton « Réinitialiser les filtres » quand quelque chose est sélectionné
C’est un point de friction classique : l’UX veut que le bouton soit désactivé tant qu’il n’a pas de sens.
Vous pouvez styliser le bouton en fonction des inputs cochés dans le conteneur.
cr0x@server:~$ cat clear.css
.filters .clear {
opacity: 0.4;
pointer-events: none;
}
.filters:has(input:checked) .clear {
opacity: 1;
pointer-events: auto;
}
Ce n’est pas juste joli. Cela évite les clics sans effet et réduit le bruit backend (« clear » spammé sans filtres).
Les petites améliorations UI se traduisent par un calme opérationnel mesurable.
Stylisation d’accordéon via ARIA
cr0x@server:~$ cat accordion.css
.filter-group:has(button[aria-expanded="true"]) {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
.filter-group:has(button[aria-expanded="true"]) .filter-group__chevron {
transform: rotate(180deg);
}
Encore : l’attribut ARIA est l’état. Le CSS réagit. Le JS ne fait que basculer aria-expanded sur le bouton,
ce qu’il devrait faire de toute façon pour l’accessibilité.
Blague n°2 : La meilleure fonctionnalité de :has() est qu’il réduit les « petits automates d’état » en JS — parce que vous aviez définitivement besoin d’en avoir moins.
Modèle mental de performance : quand :has() est peu coûteux vs risqué
La peur autour de :has() n’est pas irrationnelle. Il peut augmenter le travail que le navigateur effectue quand le DOM change,
parce qu’il introduit des sélecteurs dont la correspondance dépend des descendants.
Mais le monde réel est nuancé : de nombreuses UI sont goulot d’étranglement côté JavaScript, layout, images ou réseau.
Si :has() supprime des observers JS et réduit les rerenders, il peut être gagnant net.
La question n’est pas « est-ce que :has() est lent ? » La question est « l’avez-vous rendu non borné ? »
Faire : scopez :has() aux racines de composants
Bon : .field:has(input:invalid), .card:has(.badge--deal), .filters:has(input:checked).
Ceux-ci sont bornés par une classe racine de composant et couvrent typiquement un petit sous-arbre.
Ne pas faire : écrire des sélecteurs globaux qui scannent le monde
Mauvais : body:has(input:invalid) (vous venez de faire réagir toute la page à n’importe quel input invalide),
ou main:has(.badge) quand il y a des milliers de badges.
Sachez ce qui déclenche un recalcul
- Changer des attributs qui affectent le sélecteur interne (par ex. basculer
aria-expanded,checked,disabled). - Ajouter/retirer des nœuds descendants qui matent le sélecteur interne (mutations DOM).
- Changements d’état comme
:hover,:focus,:invalid— ceux-ci peuvent arriver fréquemment.
Règle opérationnelle simple
Si le sélecteur interne peut changer à chaque mouvement de souris (:hover) sur un grand sous-arbre, considérez-le comme un risque de performance.
S’il ne change que sur des actions discrètes (basculement de checkbox, soumission de formulaire, expansion), c’est généralement acceptable.
Ce n’est pas du vent. C’est la même chose qu’on fait en exploitation : vous pouvez vous permettre du travail coûteux lors des déploiements et incidents.
Vous ne pouvez pas vous le permettre sur chaque requête.
Tâches pratiques (commandes, sortie et la décision que vous prenez)
Vous demandez du niveau production. Cela signifie des contrôles répétables, pas des impressions.
Ci-dessous des tâches pratiques à exécuter localement ou en CI pour valider l’usage de :has(), le risque de performance et le comportement de secours.
Chacune inclut : une commande, ce que signifie la sortie, et la décision à prendre.
Task 1: Find :has() usage across the repo
cr0x@server:~$ rg -n --hidden --glob '!**/node_modules/**' ':has\(' .
src/styles/forms.css:12:.field:has(input:invalid) {
src/styles/cards.css:41:.card:has(.badge--deal) {
src/styles/filters.css:7:.filters:has(input:checked) .clear {
Output means: You have three selector sites using :has().
Decision: Require each use to be scoped to a component root class and reviewed for inner selector volatility.
Task 2: Detect risky global :has() patterns
cr0x@server:~$ rg -n ':has\(' src/styles | rg -n '^(html|body|main|#app|\.app|\.page)'
src/styles/layout.css:3:body:has(.modal-open) {
Output means: There’s a global body:has(...) usage.
Decision: Either justify it (modal open state might be OK if the matching element is stable) or refactor to a more explicit state class on body.
Task 3: Verify build tooling doesn’t “polyfill” :has() into nonsense
cr0x@server:~$ node -p "require('./package.json').browserslist"
[ 'defaults', 'not IE 11', 'maintained node versions' ]
Output means: Your Browserslist is modern. Good.
Decision: Confirm your CSS pipeline is not attempting to transform :has(). Prefer leaving it intact; partial polyfills can be worse than no support.
Task 4: Check what your compiled CSS actually contains
cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 182K dist/assets/app.css
Output means: You have a compiled stylesheet to inspect.
Decision: Grep it to ensure :has() selectors survive minification and aren’t duplicated excessively.
Task 5: Confirm :has() survived compilation/minification
cr0x@server:~$ rg -n ':has\(' dist/assets/app.css | head
1:.field:has(input:invalid){border-color:#c1121f}
1:.filters:has(input:checked) .clear{opacity:1;pointer-events:auto}
Output means: The selector is present in shipped assets.
Decision: Proceed with progressive enhancement; don’t rely on a build-time rewrite.
Task 6: Measure CSS size deltas when removing JS state classes
cr0x@server:~$ gzip -c dist/assets/app.css | wc -c
42191
Output means: Gzipped CSS size is ~42KB.
Decision: If swapping JS class toggles for :has() reduces JS more than it increases CSS, it’s often a net win for page interactivity.
Task 7: Find JS code that toggles wrapper classes (candidate for deletion)
cr0x@server:~$ rg -n "classList\.add\(|classList\.toggle\(" src | rg -n "(invalid|error|has-|active|dirty)"
src/ui/formField.ts:88:wrapper.classList.toggle("is-invalid", !input.checkValidity())
src/ui/filters.ts:52:group.classList.toggle("is-active", anyChecked)
Output means: You’re manually propagating child state to parent classes.
Decision: Replace where safe with :has() and keep JS focused on behavior and accessibility state, not styling state.
Task 8: Confirm there’s no reliance on unsupported browsers in production traffic (via logs)
cr0x@server:~$ zgrep -h "User-Agent" /var/log/nginx/access.log* | head -n 3
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36
Output means: Modern engines dominate your sample.
Decision: If you still have a long tail (older enterprise browsers), design fallbacks: functional UI first, enhanced styling second.
Task 9: Sanity-check that your critical UI still works without :has()
cr0x@server:~$ chromium --user-data-dir=/tmp/chromium-has-test --disable-features=CSSHasPseudoClass https://localhost:5173/
[19023:19023:1229/120102.103129:INFO:chrome_main_delegate.cc(785)] Starting Chromium...
Output means: You launched Chromium with :has() disabled (feature flag name can vary by version).
Decision: If form errors become invisible, you failed progressive enhancement. Keep error messages visible by default; use :has() for polish.
Task 10: Record a performance trace focusing on style recalculation
cr0x@server:~$ chromium --enable-logging=stderr --v=1 https://localhost:5173/
[19077:19077:1229/120222.411283:INFO:content_main_runner_impl.cc(1007)] Starting content main runner
Output means: Chrome is logging; you still need DevTools Performance panel for a real trace.
Decision: If toggling a checkbox causes long “Recalculate Style” slices, audit selectors: reduce scope, avoid hover-based :has() on big containers.
Task 11: Lint for “selector bombs” (very large descendant selectors)
cr0x@server:~$ rg -n ':has\([^)]{60,}\)' src/styles
src/styles/legacy.css:19:.page:has(.content .grid .card .meta .badge[data-type="x"])
Output means: Someone wrote a long, brittle descendant chain inside :has().
Decision: Replace with a stable hook class like .badge--x or restructure markup. Long chains are fragility, not cleverness.
Task 12: Validate that your CSS selector logic isn’t accidentally matching multiple states
cr0x@server:~$ node -e 'const s=[".field:has(input:invalid)",".field:has(:required)","body:has(.modal-open)"]; console.log(s.join("\n"))'
.field:has(input:invalid)
.field:has(:required)
body:has(.modal-open)
Output means: This prints the selectors you intend to ship (use it in a quick CI smoke step alongside grep).
Decision: Require explicit review of any selector that targets body/html or uses highly dynamic pseudo-classes like :hover inside :has().
Task 13: Verify your component HTML contains the hooks your selectors expect
cr0x@server:~$ rg -n 'class="field"' src | head
src/pages/signup.html:21:<div class="field">
src/pages/settings.html:44:<div class="field">
Output means: Your templates have consistent wrapper classes.
Decision: Standardize wrapper class naming (.field, .filter-group, .card) so :has() stays local and predictable.
Task 14: Catch regressions by diffing DOM structure for critical components
cr0x@server:~$ git diff --stat origin/main...HEAD -- src/components/FormField.html
src/components/FormField.html | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
Output means: The component structure changed (even if line count stays same).
Decision: Run a quick visual regression or DOM snapshot test when the structure changes, because :has() depends on it.
Mode opératoire de diagnostic rapide
Quand quelqu’un dit « après qu’on a ajouté :has(), la page est saccadée », ne débattez pas. Diagnostiquez.
Le goulot est généralement l’un des trois : la portée du sélecteur, la fréquence d’invalidation, ou le thrash de layout causé par les styles appliqués.
Première étape : identifiez le sélecteur qui étend l’ensemble des correspondances
- Cherchez des racines globales :
html:has,body:has,main:has,#app:has. - Cherchez des sélecteurs internes larges :
:has(*:hover),:has(:focus),:has(.thing)où.thingest commun site-wide. - Décision : scopez-le à une racine de composant ou ajoutez un hook dédié qui n’apparaît que là où c’est nécessaire.
Deuxième étape : vérifiez ce qui déclenche le recalcul
- Si c’est
:hoverou:focus, vous l’avez rendu dépendant de la fréquence d’événements. - Si c’est
:checkedouaria-expanded, c’est dépendant d’actions (généralement plus sûr). - Décision : évitez
:has()piloté par hover sur de grands conteneurs ; gardez les effets hover sur l’élément survolé lui-même, ou un ancêtre réduit.
Troisième étape : inspectez les styles appliqués (le piège classique « a l’air innocent »)
- Les ombres de boîte et les filtres peuvent être coûteux quand ils sont appliqués largement.
- Changer des propriétés affectant le layout (comme
display,position,height) peut déclencher des reflows. Parfois c’est acceptable, parfois catastrophique. - Décision : limitez les effets de
:has()principalement aux propriétés de painting (couleur de bordure, fond, outline, couleur de texte) sauf si vous avez profilé.
Quatrième étape : confirmez le comportement de secours
- Si le navigateur ne supporte pas
:has(), l’UI communique-t-elle toujours erreurs et états ? - Décision : affichez le texte d’erreur par défaut (ou au submit), et utilisez
:has()pour l’embellissement du wrapper.
Erreurs courantes : symptôme → cause racine → correctif
1) « Toute la page clignote quand je survole une grille de cartes. »
Symptôme : Survoler une carte provoque un repaint ou un jitter notable.
Cause racine : Un ancêtre large utilise :has(:hover) ou similaire, provoquant des recalculs fréquents de style sur un grand sous-arbre.
Correctif : Appliquez les styles de hover à l’élément survolé directement, ou restreignez :has() à la carte elle-même : .card:has(:hover) reste étrange ; préférez .card:hover et utilisez :has() pour l’état non-hover.
2) « Les champs invalides ne sont pas mis en évidence sur certains navigateurs. »
Symptôme : Les bordures des wrappers ne deviennent pas rouges ; les utilisateurs manquent les erreurs.
Cause racine : S’appuyer sur :has() pour la visibilité d’erreur essentielle sans fallback.
Correctif : Rendez les messages d’erreur visibles et les inputs stylés de manière basique ; traitez la décoration du wrapper comme une amélioration. Si vous avez besoin d’un support large, gardez une classe minimale .is-invalid comme fallback.
3) « Un wrapper de champ est rouge alors que l’input semble valide. »
Symptôme : Le wrapper affiche le style invalide, mais l’input ciblé est ok.
Cause racine : Le wrapper contient plusieurs inputs, et un input caché ou non relié est invalide.
Correctif : Réduisez le sélecteur interne au contrôle spécifique : .field:has(input.field__control:invalid). N’utilisez pas input:invalid si vous imbriquez plusieurs inputs.
4) « Notre carte reçoit le style ‘deal’ parce qu’un badge non lié est dans un composant imbriqué. »
Symptôme : Faux positifs : le style se déclenche quand il ne devrait pas.
Cause racine : Sélecteur interne trop générique (ex. :has(.badge) au lieu de :has(.badge--deal)).
Correctif : Utilisez des classes modificateurs explicites pour la sémantique. :has() n’est pas une licence pour des sélecteurs vagues.
5) « Le bouton Réinitialiser les filtres est activé, mais aucun filtre n’est réellement appliqué. »
Symptôme : L’UI indique un état que le backend ne partage pas.
Cause racine : La barre latérale contient des checkboxes pour sélections « appliquées » et « en brouillon » ; :has(input:checked) ne peut pas distinguer.
Correctif : Ajoutez un attribut ou une classe pour marquer l’état appliqué : .filters:has(input[data-applied="true"]:checked), ou séparez les régions DOM appliquées vs brouillon.
6) « Un petit refactor DOM a cassé la moitié du style. »
Symptôme : Un revirement UI déplace le balisage ; le comportement visuel change de façon inattendue.
Cause racine : :has() dépend des relations DOM ; les chaînes de descendants fragiles ont amplifié cette dépendance.
Correctif : Gardez les sélecteurs :has() superficiels, et reposez-vous sur des hooks stables à l’intérieur du composant. Ajoutez des tests snapshot/visuels pour les composants avec des sélecteurs relationnels.
Listes de contrôle / plan pas à pas
Adopter :has() en toute sécurité (pas à pas)
- Choisissez un type de composant. Commencez par les champs de formulaire ou les groupes de filtres. Ne faites pas un refactor site-wide.
- Définissez le sélecteur racine. Exemple :
.field,.filter-group,.card. Si vous n’en avez pas, ajoutez-le. - Choisissez des sélecteurs internes stables et spécifiques. Exemple :
input.field__control:invalid,.badge--deal,button[aria-expanded="true"]. - Rendez l’UX de base fonctionnelle sans
:has(). Les erreurs doivent être lisibles. Les boutons doivent fonctionner. Aucun état essentiel ne doit être communiqué uniquement par la décor du wrapper. - Utilisez
@supports selector(:has(*))pour les améliorations risquées. Ce n’est pas toujours requis, mais c’est une garde propre quand vous changez le layout ou affichez/masquez des éléments. - Profilez une interaction. Basculez une checkbox, focussez des inputs, étendez un accordéon. Cherchez des longues tranches « Recalculate Style ».
- Supprimez le JS qui ne fait que propager des classes. Gardez le JS qui gère le comportement et l’accessibilité.
- Ajoutez un test de régression. Snapshots visuels pour les états du composant : défaut, focus, invalide, erreur serveur.
Checklist qualité des sélecteurs (version mentale imprimable)
- Le côté gauche est-il une racine de composant (pas
body) ? - Le sélecteur interne est-il étroit (pas une longue chaîne de descendants) ?
- Le sélecteur interne change-t-il à haute fréquence (hover/mouse move) ? Si oui, repensez-le.
- Des descendants cachés ou non liés vont-ils matcher par erreur ?
- Ce sera-t-il acceptable si le composant est rendu 200 fois sur une page ?
- L’UX est-elle acceptable sans
:has()?
Trois micro-histoires d’entreprise issues du terrain
Micro-histoire 1 : L’incident causé par une mauvaise hypothèse
Une équipe a modernisé une grosse page de réglages : beaucoup de sections répétées, composants imbriqués, et un pattern « Ajouter un autre » inline.
Ils ont remplacé un mécanisme JS d’« invalid wrapper » par .section:has(input:invalid) et l’ont déployé derrière un petit feature flag.
Ça avait l’air propre. Les tests passaient. Tout le monde est passé à autre chose.
Puis des tickets support ont commencé : « Je ne peux pas enregistrer les réglages ; ça me renvoie en arrière. » Le bug n’était pas dans la sauvegarde.
La page défilait vers la première section invalide lors du submit. Cette logique de scroll cherchait .is-invalid wrappers
(une classe que l’ancien JS mettait). Le nouveau style uniquement CSS n’avait pas défini la classe — parce que c’était du CSS.
La mauvaise hypothèse était subtile : « Si ça a l’air invalide, c’est invalide, et le code peut le trouver. »
Mais le style n’est pas de l’état. Le CSS ne peut pas être interrogé de manière fiable depuis le JS comme vous le voudriez.
Ils avaient supprimé accidentellement un signal sémantique dont d’autres codes dépendaient.
La correction a été ennuyeuse et correcte : garder un attribut aria-invalid="true" explicite et mettre à jour le code de scroll pour cibler ça,
tout en conservant :has() pour la décoration du wrapper. Le JS de toggling de classes est resté supprimé. L’état est resté découvrable.
La leçon : utilisez :has() pour exprimer la présentation dérivée d’un état, pas pour remplacer l’état lui‑même.
Si d’autres codes doivent réagir, donnez-leur quelque chose de sémantique comme ARIA ou un attribut data.
Micro-histoire 2 : L’optimisation qui a mal tourné
Une autre organisation a livré une nouvelle page « catalogue » avec des milliers de cartes (oui, des milliers).
Un ingénieur a décidé de réduire le travail DOM en supprimant des classes modificateurs générées côté serveur.
Au lieu de rendre .card--featured, le template rendrait un élément .badge et laisserait le CSS faire le reste :
.card:has(.badge--featured). Élégant.
Une semaine plus tard, les tableaux de bord de performance montraient une latence d’interaction plus mauvaise pendant le scroll et le filtrage.
Pas un incendie total, mais suffisant pour agacer les utilisateurs mobiles. Les traces DevTools montraient des coûts de recalcul de style plus élevés lors des mises à jour de la liste.
La raison n’était pas mystique : l’UI de filtre mettait fréquemment à jour le DOM, et chaque mise à jour signifiait plus de matching de sélecteurs
à travers une grosse liste.
L’« optimisation » avait aussi un coût caché : le balisage des badges était plus dynamique que l’ancienne classe modificateur.
Les A/B tests ont inséré de nouveaux types de badges, et maintenant plusieurs règles :has() entraient en compétition. La cascade est devenue compliquée.
Le CSS restait correct, mais il fallait être un magicien pour le prédire.
La stratégie de rollback a été pragmatique : garder :has() pour les petites listes et composants locaux,
mais restaurer des classes modificateurs explicites pour les collections massives répétées. Le CSS est resté plus simple, et le moteur a fait moins de matching relationnel.
La leçon : :has() n’est pas automatiquement moins coûteux qu’une classe. Sur de grandes collections avec du churn DOM fréquent,
des classes d’état explicites peuvent être un meilleur compromis de performance.
Micro-histoire 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Une équipe paiements a introduit :has() pour améliorer leurs wrappers de formulaire. Ils ont été prudents : chaque règle était derrière
@supports selector(:has(*)) et le style de secours était volontairement « suffisant ».
Ils ont aussi écrit un petit test de composant qui rendait le champ dans quatre états : défaut, focus, invalide, erreur serveur.
Six mois plus tard, un partenaire a embarqué le formulaire de paiement dans un WebView avec un ancien moteur.
Le style du wrapper ne s’appliquait pas. Mais le formulaire fonctionnait toujours. Les messages d’erreur étaient visibles, les anneaux de focus existaient,
et le flux de soumission fonctionnait. Pas d’incident. Pas de messages à minuit. Juste une remarque « moins soigné ».
Le coup de grâce : une autre équipe sans ces garde-fous avait livré une fonctionnalité similaire ailleurs, et sur les anciens moteurs le texte d’erreur
était caché par défaut et seulement révélé par des sélecteurs :has(). Les utilisateurs ne voyaient pas ce qui n’allait pas.
Ça a été un vrai ticket support.
La pratique ennuyeuse n’était pas héroïque. C’était : enhancement progressif, fallback explicite, et tests d’états.
Le payoff opérationnel était réel : moins de défaillances visibles aux utilisateurs dans des environnements clients imprévisibles.
FAQ
1) Est-ce que :has() est sûr à utiliser en production ?
Oui, si vous utilisez l’enhancement progressif et évitez les sélecteurs non bornés. Traitez-le comme toute fonctionnalité moderne de plateforme :
définissez une expérience de base, puis améliorez quand c’est supporté.
2) Dois‑je entourer les règles :has() avec @supports ?
Si la règle affecte l’utilisabilité essentielle (afficher/masquer des erreurs, changements de layout), oui. Si c’est purement décoratif et que vous acceptez qu’il ne s’applique pas, c’est optionnel.
La garde ressemble à : @supports selector(:has(*)) { ... }.
3) Puis‑je utiliser :has() comme remplacement de l’état JS ?
Remplacez la propagation de l’état de présentation, pas l’état métier. Si le code doit savoir qu’une chose est invalide/étendue/active,
exprimez‑la via des attributs ou classes. Le CSS peut ensuite en déduire l’affichage avec :has().
4) Est‑ce que :has() nuit à la performance ?
Ça peut, surtout lorsqu’il est utilisé sur de grands conteneurs avec des descendants changeant fréquemment.
Scopez les sélecteurs aux racines de composants et évitez les pseudo‑classes haute‑fréquence comme :hover dans :has() sur de grands sous-arbres.
Profilez l’interaction spécifique qui vous importe.
5) :has() est‑il mieux que d’ajouter une classe comme .is-invalid ?
C’est mieux quand l’état est déjà présent dans le DOM (par ex. :invalid, :checked, attributs ARIA) et que vous voulez éviter du glue JS.
Une classe est mieux quand vous traitez de listes lourdes, d’état cross‑composant, ou quand le JS calcule déjà l’état.
6) Puis‑je utiliser :has() pour compter les filtres sélectionnés ?
Pas directement. Le CSS ne peut pas calculer des comptes de façon maintenable. Utilisez le JS pour calculer les comptes, rendre un nombre, et utilisez :has() pour le style binaire comme « actif/inactif ».
7) Comment déboguer des sélecteurs :has() ?
Commencez par isoler la correspondance : appliquez temporairement un outline voyant au sélecteur gauche, puis resserrez le sélecteur interne.
Gardez les sélecteurs internes courts et ancrés à des classes explicites pour pouvoir rapidement raisonner sur pourquoi ça a matché.
8) :has() peut‑il remplacer :focus-within ?
Pas remplacer, mais compléter. :focus-within est un moyen dédié et efficace pour styliser les ancêtres quand le focus est à l’intérieur.
Utilisez‑le pour le focus. Utilisez :has() quand vous avez besoin de conditions plus riches que « un descendant focusse ».
9) Quel est le meilleur premier cas d’usage pour :has() ?
Les wrappers de champs de formulaire réagissant à :invalid, :required et à l’état ARIA invalid. Ça supprime du JS verbeux et améliore immédiatement la cohérence UX.
Étapes suivantes à livrer cette semaine
Faites cela dans l’ordre, car les systèmes de production récompensent une séquence ennuyeuse.
- Choisissez une famille de composants : wrappers de champs, groupes de filtres ou cartes.
- Ajoutez ou confirmez une classe racine du composant afin que votre
:has()reste local. - Implémentez une règle relationnelle qui supprime un toggle de classe JS existant. Gardez l’ancien comportement derrière un feature flag si vous êtes prudent.
- Protégez les améliorations risquées avec
@supports selector(:has(*))et assurez‑vous que l’UX de base communique toujours l’état. - Exécutez les contrôles du repo : grep pour sélecteurs globaux, longues chaînes de descendants, et patterns lourds en hover.
- Profilez une interaction réelle (basculement de filtre, input invalide, expansion d’accordéon) et vérifiez que le recalcul de style ne domine pas.
- Ajoutez une petite suite de régression pour les états UI clés dépendant de
:has().
:has() est une de ces fonctionnalités qui donne l’impression que la plateforme a enfin rattrapé notre façon de construire des UI.
Utilisez‑le comme un adulte : scopez, testez, profilez, avec une dégradation progressive. Votre bundle JS se réduit, votre DOM devient plus sain,
et votre rotation d’astreinte devient plus calme — ce qui est le seul KPI qui compte à 2 h du matin.