CSS : les pseudo-classes modernes à connaître
Pourquoi ces pseudo-classes comptent
Pendant longtemps, certains comportements d'interface obligeaient à écrire du JavaScript — même pour des choses purement visuelles. "Styliser une carte quand une case cochée à l'intérieur change d'état", "cibler un parent selon son contenu"... c'étaient des cas typiques où CSS seul ne suffisait pas.
Les pseudo-classes modernes changent la donne. Elles couvrent des besoins réels et fréquents, sans une seule ligne de script.
Le fil conducteur de cet article : chaque pseudo-classe présentée ici remplace un pattern que tu as probablement déjà résolu avec un useState + un onChange côté React. Moins de JS, moins de re-renders, un rendu plus rapide.
:has() — le sélecteur parent
C'est la pseudo-classe la plus attendue de ces dernières années. Jusqu'ici, CSS pouvait cibler un enfant selon son parent (.card p), mais jamais l'inverse. :has() comble ce manque : elle sélectionne un élément selon ce qu'il contient.
/* Stylise la carte entière si elle contient une checkbox cochée */
.card:has(input:checked) {
border-color: #1d9e75;
background-color: #e1f5ee;
}
Tu viens de voir cette exacte démo plus haut dans la conversation : les trois cartes changent de couleur au clic, sans le moindre onClick ni useState — juste une règle CSS.
Autre cas très courant : styliser un champ de formulaire différemment selon qu'il contient une erreur ou non, en combinant :has() avec :invalid :
/* Le conteneur du champ devient rouge si l'input est invalide */
.form-field:has(input:invalid) {
border-color: #d64545;
}
.form-field:has(input:invalid) .form-field__error {
display: block;
}
Applicable directement sur ton ContactForm.tsx : tu peux styliser le conteneur .form-field autour de chaque input sans avoir à gérer un état d'erreur en React pour chaque champ, juste avec la validation HTML native (required, type="email"...).
:has() fonctionne aussi comme sélecteur de proximité, pour cibler un élément selon ce qui le suit :
/* Ajoute une marge à un titre seulement s'il est suivi d'un paragraphe */
h2:has(+ p) {
margin-bottom: 0.5rem;
}
:is() et :where() — factoriser des sélecteurs
Ces deux pseudo-classes regroupent plusieurs sélecteurs en une seule règle, pour éviter la répétition. Sans elles, cibler plusieurs titres à l'intérieur de plusieurs conteneurs oblige à tout écrire en toutes lettres :
/* Sans :is() — répétitif */
.article-detail h1,
.article-detail h2,
.article-detail h3,
.project-detail h1,
.project-detail h2,
.project-detail h3 {
font-family: var(--font-heading);
}
/* Avec :is() — factorisé */
:is(.article-detail, .project-detail) :is(h1, h2, h3) {
font-family: var(--font-heading);
}
La différence entre les deux tient uniquement à la spécificité CSS — ce qui détermine quelle règle l'emporte en cas de conflit :
:is()
- Prend la spécificité de son argument le plus fort
- Peut écraser d'autres règles existantes
- À utiliser quand la priorité normale est voulue
:where()
- A toujours une spécificité de zéro
- Ne peut jamais écraser une autre règle
- Idéal pour des styles de base facilement surchargeables
/* :where() a une spécificité nulle : n'importe quelle autre règle
peut la surcharger sans avoir besoin d'un sélecteur plus précis */
:where(.article-card, .project-card) {
border-radius: 16px;
}
/* Cette règle plus simple suffit à surcharger la précédente */
.article-card {
border-radius: 20px;
}
:focus-visible — un focus qui respecte l'utilisateur
Le vieux problème du focus CSS : :focus s'applique aussi bien à un clic souris qu'à une navigation clavier — ce qui pousse beaucoup de sites à masquer le contour de focus par défaut (outline: none), cassant au passage l'accessibilité pour les utilisateurs clavier.
:focus-visible résout ça proprement : le navigateur décide lui-même, selon le contexte, si le focus doit être visible ou non.
/* Retire l'anneau de focus disgracieux au clic souris... */
button:focus {
outline: none;
}
/* ...mais le restaure proprement à la navigation clavier */
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
Ne fais jamais outline: none sans le compenser par :focus-visible. C'est l'une des erreurs d'accessibilité les plus fréquentes — elle rend un site inutilisable au clavier, ce qui touche directement les critères RGAA que tu appliques sur tes projets.
:focus-within — styliser un conteneur entier
Autre besoin classique en formulaire : mettre en valeur tout un groupe de champs dès qu'un utilisateur interagit avec l'un d'eux à l'intérieur. :focus-within s'applique à un parent dès qu'un de ses descendants reçoit le focus.
/* Le conteneur entier réagit dès qu'un input à l'intérieur est actif */
.form-group:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px hsla(var(--primary-hsl), 0.15);
}
C'est le comportement exact qu'on retrouve sur beaucoup de formulaires modernes : le cadre entier "s'illumine" légèrement quand tu commences à taper, sans que chaque input individuel ait besoin de son propre style de focus.
:nth-child(An+B of S) — cibler dans un sous-ensemble
:nth-child() seule cible une position selon tous les enfants d'un parent. La syntaxe étendue of S restreint ce calcul à un sous-ensemble filtré — très utile sur une liste hétérogène.
/* Cible le 2e élément .featured parmi les enfants, en ignorant les autres */
.grid > :nth-child(2 of .featured) {
grid-column: span 2;
}
Exemple concret pour une grille d'articles où certains sont marqués comme "à la une" : sans of S, :nth-child(2) cible le 2e enfant peu importe son type. Avec of .featured, il cible précisément le 2e élément parmi ceux qui ont la classe .featured — les cartes normales entre les deux n'influencent pas le calcul.
:not() avec plusieurs arguments
Depuis la spécification Selectors Level 4, :not() accepte une liste de sélecteurs séparés par des virgules, plutôt qu'un seul argument à la fois.
/* Avant : fallait chaîner plusieurs :not() */
.card:not(.featured):not(.archived):not(.draft) {
opacity: 1;
}
/* Maintenant : une seule pseudo-classe, une liste */
.card:not(.featured, .archived, .draft) {
opacity: 1;
}
Sur une grille de composants, ça permet d'exclure proprement plusieurs états sans empiler les sélecteurs — plus lisible, surtout quand la liste des exceptions grandit avec le temps.
Compatibilité navigateurs
| Pseudo-classe | Support | Remarque |
|---|---|---|
:has() |
Tous navigateurs modernes depuis fin 2023 | Safari a été précurseur, Chrome et Firefox ont suivi |
:is() / :where() |
Large support depuis 2021 | Aucune restriction notable aujourd'hui |
:focus-visible |
Large support depuis 2021 | À privilégier systématiquement sur :focus seul |
:focus-within |
Support quasi universel depuis longtemps | La plus ancienne de cette liste |
:nth-child(of S) |
Support récent, encore partiel | Vérifier sur caniuse.com avant un usage critique |
:not() multi-arguments |
Large support depuis 2023 | Fonctionne partout où :has() fonctionne |
Pour un projet grand public avec des exigences de compatibilité strictes, garde le réflexe de vérifier caniuse.com avant d'utiliser :has() ou la syntaxe of S comme mécanisme critique — mais pour un effet purement esthétique, un dégradé sans plantage sur les navigateurs plus anciens (l'élément reste juste non stylisé) est généralement acceptable.
En résumé
| Pseudo-classe | Remplace souvent |
|---|---|
:has() |
Un onChange + useState juste pour du style conditionnel |
:is() / :where() |
Des listes de sélecteurs répétitives |
:focus-visible |
Un outline: none qui casse l'accessibilité clavier |
:focus-within |
Un état de focus géré manuellement sur un groupe de champs |
:nth-child(of S) |
Un filtrage + comptage fait en JavaScript |
:not() multi-args |
Plusieurs :not() chaînés |
Le point commun de toutes ces pseudo-classes : elles permettent d'exprimer directement en CSS des logiques qui, il y a quelques années encore, nécessitaient forcément du JavaScript. Moins de code, moins de state à synchroniser, et des interfaces qui restent réactives même si le script n'a pas encore fini de charger.
