~8 min de lecture
CSS :has() en Angular : 5 patterns qui remplacent du JS
Tu veux qu'un conteneur change d'apparence quand l'input à l'intérieur est invalide. Réflexe : un effect() qui lit un signal, ou un [class.is-invalid] bindé sur l'état du contrôle. Ça marche. Mais tu viens d'écrire du JavaScript, et un bout d'état réactif de plus, pour un lien parent-enfant que le CSS sait exprimer seul. Multiplie ça par tous les endroits où un élément doit réagir à un autre, et tu as réinventé en TypeScript ce que le navigateur fait en une ligne.
:has() inverse la logique CSS classique. Jusqu'ici, un sélecteur ne pouvait styler qu'un élément selon ses ancêtres ou ses frères précédents. :has() te laisse enfin styler un parent selon ce qu'il contient. Un parent selon l'état d'un de ses enfants. C'est le "sélecteur parent" que le CSS attendait depuis quinze ans, et il est Baseline depuis fin 2023 : tous les navigateurs evergreen le supportent.
Le point qui nous intéresse ici, côté Angular : chaque :has() bien placé, c'est un effect(), un [class.x], ou un listener en moins. Donc du code qui disparaît, et du style qui marche dès le SSR, avant que le moindre JS ne boote. On regarde cinq patterns concrets, ancrés sur le code de ce repo (la landing Angular 21 qui sert ce blog).
Pattern 1 : le thème par attribut (le parent réagit à un descendant)
C'est le pattern fondateur, et ce site l'utilise en production. Les trois produits (EAK, AAK, RAK) partagent la même surface visuelle mais changent d'accent. La bascule se fait par un seul attribut posé sur le host de la page produit, et le token de couleur suit via :has() :
/* styles.css : l'accent bascule selon la page produit courante */
:has([data-product='aak']) {
--product-accent: var(--color-coral);
--product-accent-secondary: var(--color-coral-deep);
}
:has([data-product='rak']) {
--product-accent: var(--color-emerald);
--product-accent-secondary: var(--color-emerald-deep);
}
Côté Angular, poser l'attribut ne coûte rien de plus qu'un binding statique dans le décorateur :
// aak-home.page.ts (page lazy-routée : pas de selector, l'attribut suffit)
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { '[attr.data-product]': "'aak'" },
// ...
})
export default class AakHomePage {}
L'alternative sans :has() : un effect() global qui écoute la navigation, lit la route active, et toggle une classe de thème sur document.body. C'est exactement ce qu'on évite. Ici le CSS lit l'attribut lui-même. Et comme l'attribut est rendu par Angular côté serveur, le bon thème est présent dans le HTML SSR : pas de flash de couleur générique le temps que l'hydratation applique la classe. Si tu veux le détail de cette approche pour du co-branding multi-marques, elle est dépliée dans l'article sur le lazy-loading du CSS de co-branding ; ici on en fait juste le premier des cinq patterns.
Pattern 2 : styler le champ selon l'état de son input
Le cas d'école, celui qui justifie à lui seul d'apprendre :has(). Tu veux qu'un wrapper de champ réagisse quand l'input dedans est focus, invalide, ou rempli. Sans :has(), tu bindes l'état :
<!-- Avant : le wrapper porte l'état de l'input, donc il faut le calculer en TS -->
<div class="field" [class.field--invalid]="ctrl.invalid() && ctrl.touched()">
<input [formControl]="ctrl" />
</div>
Le problème n'est pas la ligne de template, c'est ce qu'elle traîne : un contrôle exposé, un état lu à chaque détection de changement, une classe à maintenir. Or Angular fait déjà le travail pour toi. Dès qu'un input est lié à un formulaire (formControl, formControlName ou ngModel), Angular lui pose automatiquement ses classes d'état : ng-invalid / ng-valid, ng-touched / ng-untouched, ng-dirty / ng-pristine. :has() n'a plus qu'à les lire :
/* Angular pose ng-invalid + ng-touched tout seul sur le contrôle : :has() les lit */
.field:has(input.ng-invalid.ng-touched) {
border-color: var(--color-danger);
}
.field:has(input:focus-visible) {
outline: 2px solid var(--product-accent);
}
/* label flottant : monte dès que l'input est rempli (l'input doit porter un placeholder) */
.field:has(input:not(:placeholder-shown)) label {
transform: translateY(-1.4rem) scale(0.85);
}
Cibler .ng-invalid.ng-touched te donne pile le comportement "n'affiche l'erreur qu'une fois le champ quitté" sans une ligne de TypeScript, sans touched() dans le template, sans exposer le contrôle. C'est Angular qui pose les classes, :has() qui les remonte au wrapper.
Le piège que presque tout le monde rate ici : n'utilise pas :user-invalid ou :invalid avec des Reactive Forms. Ces pseudo-classes natives sont pilotées par la validation HTML du navigateur (required, pattern, type="email"), pas par les Validators d'Angular. Un champ validé uniquement côté Angular porte ng-invalid mais reste :valid pour le navigateur, donc :has(input:user-invalid) ne matcherait jamais. Vise les classes d'Angular. :user-invalid ne redevient pertinent que si tu déposes aussi les contraintes HTML natives sur l'input.
Pattern 3 : masquer une section selon son contexte ou son contenu
Ce site s'en sert pour la cross-promo. Une bannière promotionnelle s'affiche partout, sauf sur les pages produit AAK et RAK où elle ferait doublon avec le CTA du hero. Zéro @if, zéro logique dans le composant bannière :
/* styles.css : la bannière se masque elle-même selon la page courante */
body:has([data-product='aak']) app-promo-banner,
body:has([data-product='rak']) app-promo-banner {
display: none;
}
Le même mécanisme couvre le cas "section vide". Combien de fois tu écris @if (items().length) autour d'un bloc juste pour ne pas afficher un titre au-dessus d'une liste vide ? :has() le fait en CSS pur :
/* masque le bloc entier s'il ne contient aucune carte rendue */
.recommendations:not(:has(.card)) {
display: none;
}
L'intérêt dépasse la ligne de code économisée. Un @if qui dépend d'un signal se réévalue dans le cycle de détection de changement ; ce :not(:has()) est un pur calcul de style, hors du graphe réactif d'Angular. Tu déplaces une décision d'affichage du runtime JS vers le moteur CSS, qui est fait pour ça.
Pattern 4 : le bouton submit qui suit la validité du formulaire entier
Grille classique : le bouton d'envoi doit avoir l'air désactivé tant que le formulaire n'est pas valide. La version réactive marche, mais elle relie l'apparence du bouton à un signal de validité recalculé en permanence :
<!-- Avant : l'état visuel du bouton dépend d'un signal de formulaire -->
<button type="submit" [class.is-disabled]="form.invalid()">Envoyer</button>
:has() remonte du contrôle invalide jusqu'au formulaire, puis redescend vers le bouton, en une seule règle. On cible .ng-invalid sans .ng-touched cette fois : Angular marque les contrôles requis invalides dès l'initialisation, donc le bouton est grisé d'entrée sur un formulaire vide, exactement comme le ferait form.invalid().
/* le formulaire contient un contrôle invalide => le bouton paraît inactif */
form:has(.ng-invalid) button[type='submit'] {
opacity: 0.5;
pointer-events: none;
}
Précision honnête, parce qu'elle compte : garde le [disabled] réel piloté par Angular pour l'accessibilité et la sécurité (un bouton doit être réellement désactivé, pas juste grisé). :has() couvre la couche visuelle, pas la logique de soumission. Le partage est net : Angular décide si le formulaire part, le CSS décide de quoi il a l'air. Tu arrêtes juste de dupliquer l'état de validité dans une classe.
Pattern 5 : le layout qui s'adapte à un slot optionnel
Une carte qui s'affiche avec ou sans image, ou une grille dont l'espacement dépend du nombre d'éléments. Le réflexe, c'est de binder une classe selon la présence du contenu ([class.has-image]), ce qui suppose de recalculer l'état en TS. :has() interroge le DOM directement :
/* la carte passe en deux colonnes seulement quand elle contient une image */
.card:has(img) {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
}
/* variante quantité : espacement resserré dès que la grille dépasse 6 enfants */
.grid:has(> :nth-child(7)) {
gap: 0.5rem;
}
Ce dernier exemple, la "quantity query", est impossible à faire proprement en CSS sans :has() : styler un conteneur selon combien d'enfants il porte. Avant, ça demandait un ResizeObserver ou un compteur en TS. Là, c'est une ligne, et elle réagit en direct quand @for ajoute ou retire des éléments, sans que tu câbles quoi que ce soit. Un point de vigilance si l'image ou les éléments arrivent par <ng-content> plutôt que par le template propre de la carte : voir le piège d'encapsulation juste en dessous.
Le piège Angular : encapsulation et portée
Un :has() mal placé ne matche rien, et c'est presque toujours une histoire d'encapsulation. Angular scope par défaut les styles d'un composant à son propre DOM (ViewEncapsulation émulée). Donc :
- Dans les styles d'un composant,
:has()ne voit que le DOM scopé à ce composant. Pour styler le host selon son propre template,:host:has(.something)marche parfaitement. - Attention au contenu projeté : un nœud passé via
<ng-content>garde l'attribut d'encapsulation du composant parent, pas celui de l'enfant qui le reçoit. Une règle:has()scopée dans les styles de l'enfant ne le matchera donc pas de façon fiable. Si ta carte du pattern 5 reçoit son image par projection, sors la règle dansstyles.cssglobal ; si l'image vient du template propre de la carte, la règle scopée suffit. - Pour traverser les frontières de composants (une bannière masquée selon la page produit, du theming global), le
:has()doit vivre dansstyles.cssglobal. C'est exactement pourquoi les patterns 1 et 3 de ce repo sont dans le fichier global et pas dans un composant :body:has([data-product])doit voir à la fois le body et une page lazy-loadée bien plus bas dans l'arbre.
Deuxième garde-fou, la perf. :has() est optimisé, mais il reste un sélecteur qui remonte l'arbre : garde son sujet spécifique (form:has(.ng-invalid), pas *:has(...)) et évite de le brancher sur un sous-arbre gigantesque qui muterait à chaque frame. Pour du theming et de l'état de formulaire, tu es très loin de ce seuil.
Before / after
- Before : un
effect()pour le thème, un[class.is-invalid]par champ, un@ifpour masquer une section vide, un signal de validité recopié sur le bouton, une classe bindée pour le layout. Cinq bouts d'état réactif, tous inertes tant que l'hydratation n'a pas tourné. - After : cinq règles CSS. Le style est présent dans le HTML SSR, réagit sans JavaScript, et sort du graphe de détection de changement d'Angular. Tu as supprimé du code et gagné un rendu correct au premier paint.
Récap actionnable
- Theming par attribut :
:has([data-product])plutôt qu'uneffect()qui toggle une classe sur le body. Bonus SSR : le bon thème est dans le HTML dès le serveur. - État de champ :
.field:has(input.ng-invalid.ng-touched)et:has(input:focus-visible)remplacent les[class.x]bindés. Angular pose les classes d'état tout seul. Piège ::user-invalidnatif ne réagit pas auxValidatorsAngular, vise.ng-invalid. - Affichage conditionnel :
:not(:has(.item))masque une section vide hors du cycle de détection de changement, là où un@ifla garderait dans le graphe réactif. - Validité de formulaire :
form:has(.ng-invalid) buttonpour la couche visuelle (grisé d'emblée). Garde le[disabled]réel piloté par Angular pour l'a11y et la sécurité. - Layout adaptatif :
.card:has(img)et la quantity query:has(> :nth-child(7))remplacent unResizeObserverou une classe calculée en TS.
La règle mentale : chaque fois que tu écris du TypeScript pour réagir à l'état du DOM (un focus, une validité, une présence, un compte), demande-toi si :has() ne le ferait pas en une ligne, plus tôt, et gratuitement. Souvent, oui. Et le meilleur JavaScript reste celui que tu n'écris pas.