Pourquoi s'intéresser à :is() et :where() ?

Dans une feuille CSS qui grandit, le problème ne vient pas seulement du nombre de règles. Il vient surtout de leur lisibilité, de leur répétition et de leur spécificité. On commence avec quelques composants simples, puis apparaissent des variantes, des états, des zones thématiques, des pages particulières, des exceptions. Très vite, les sélecteurs deviennent longs, difficiles à relire et parfois trop puissants.

Les pseudo-classes :is() et :where() permettent de regrouper plusieurs sélecteurs dans une même expression. Elles ressemblent beaucoup à première vue, mais elles n'ont pas le même impact sur la spécificité. C'est précisément ce qui les rend intéressantes pour structurer du CSS moderne.

Elles ne remplacent pas une bonne architecture CSS. Elles complètent très bien des approches comme les cascade layers, les design tokens, le nesting CSS natif ou encore [@scope](/articles/css-scope-styles-composants). Utilisées correctement, elles permettent d'écrire moins de bruit syntaxique tout en gardant une cascade prévisible.

Le problème classique : la répétition des sélecteurs

Imaginons une interface avec plusieurs types de blocs éditoriaux : article, fiche produit, page d'aide. Chaque zone contient des titres, des paragraphes et des listes qui doivent partager certaines règles typographiques.

Sans :is(), on peut vite écrire ceci :

.article-content h2,
.article-content h3,
.article-content p,
.product-content h2,
.product-content h3,
.product-content p,
.help-content h2,
.help-content h3,
.help-content p {
  max-width: 68ch;
  line-height: 1.6;
}

Ce code fonctionne, mais il est verbeux. Il est aussi pénible à faire évoluer : ajouter blockquote ou une nouvelle zone oblige à modifier plusieurs lignes. Avec :is(), on peut exprimer la même intention de façon plus compacte :

:is(.article-content, .product-content, .help-content) :is(h2, h3, p) {
  max-width: 68ch;
  line-height: 1.6;
}

La règle devient plus lisible parce qu'elle reflète mieux la logique métier : dans ces trois zones, pour ces trois types d'éléments, appliquer ces styles. On ne lit plus une longue énumération plate, mais une combinaison explicite de groupes.

Comprendre :is()

:is() accepte une liste de sélecteurs et correspond à l'un d'entre eux. Il peut contenir des sélecteurs simples, des classes, des attributs, des pseudo-classes ou des combinaisons plus avancées.

.card:is(:hover, :focus-within) {
  box-shadow: 0 1rem 2rem rgb(0 0 0 / 0.12);
}

Ici, la carte reçoit une ombre lorsqu'elle est survolée ou lorsqu'un élément interne reçoit le focus. C'est utile pour aligner l'expérience souris et clavier, sans dupliquer la règle.

Autre exemple avec des variantes de composants :

.button:is(.is-primary, .is-danger, .is-success) {
  color: white;
}

.button.is-primary {
  background: var(--color-primary);
}

.button.is-danger {
  background: var(--color-danger);
}

.button.is-success {
  background: var(--color-success);
}

On évite ainsi de répéter color: white dans chaque variante. Ce type d'organisation s'intègre bien avec une logique de tokens, comme expliqué dans l'article sur les design tokens en CSS et TypeScript.

La spécificité de :is()

La différence importante : :is() prend la spécificité du sélecteur le plus spécifique présent dans sa liste.

Par exemple :

:is(.card, #featured-card) h2 {
  font-size: 1.5rem;
}

Même lorsque la règle cible .card h2, la présence de #featured-card dans la liste donne à l'ensemble une spécificité élevée. Cela peut rendre les surcharges plus difficiles.

C'est la principale erreur à éviter : mélanger des sélecteurs très faibles et très forts dans un même :is(). Pour du CSS de composants, gardez des listes homogènes : classes avec classes, états avec états, attributs avec attributs. Si vous avez besoin d'un identifiant ou d'un cas exceptionnel, il mérite souvent une règle séparée.

Comprendre :where()

:where() ressemble syntaxiquement à :is(), mais avec une différence majeure : sa spécificité est toujours nulle. Les sélecteurs placés à l'intérieur de :where() servent à cibler, mais n'ajoutent aucun poids dans la cascade.

C'est extrêmement utile pour écrire des styles de base faciles à surcharger.

:where(article, section, aside) :where(h2, h3, p, ul, ol) {
  margin-block-start: 0;
}

Cette règle cible beaucoup d'éléments, mais reste très faible. Une simple classe de composant pourra la remplacer sans combat de spécificité.

.prose-card p {
  margin-block-start: 1rem;
}

Même si la première règle semble plus longue, elle ne gagne pas automatiquement. C'est le grand intérêt de :where() : poser des conventions globales ou structurelles sans verrouiller le reste du système.

Un cas pratique : styles éditoriaux sobres et surchargeables

Pour une zone de contenu riche, on veut souvent appliquer des espacements cohérents aux titres, paragraphes, listes, citations et blocs de code. Mais on veut aussi laisser certains composants internes reprendre le contrôle.

.prose :where(h2, h3, h4, p, ul, ol, blockquote, pre) {
  margin-block: 0;
}

.prose :where(h2, h3, h4) {
  line-height: 1.2;
  text-wrap: balance;
}

.prose :where(p, ul, ol, blockquote) {
  max-width: 70ch;
  line-height: 1.7;
}

.prose :where(* + *) {
  margin-block-start: 1rem;
}

Le sélecteur .prose donne un minimum de contexte, mais toute la liste interne reste sans poids supplémentaire. Cela rend les styles éditoriaux beaucoup moins agressifs qu'une longue série de sélecteurs classiques.

Si un composant spécifique apparaît dans .prose, il peut reprendre la main naturellement :

.prose .alert p {
  max-width: none;
  line-height: 1.5;
}

Ce modèle est particulièrement intéressant pour les pages de documentation, les articles, les fiches produit détaillées ou les interfaces d'administration qui affichent du contenu riche.

Quand utiliser :is() plutôt que :where() ?

Utilisez :is() lorsque vous voulez réellement que le groupe participe à la spécificité normale du sélecteur. C'est souvent le bon choix pour les états interactifs, les variantes de composants ou les combinaisons de classes proches du composant.

.tabs__button:is(:hover, :focus-visible, [aria-selected="true"]) {
  color: var(--color-accent);
}

Ici, il est logique que la règle garde une spécificité de composant. Elle décrit un comportement local et intentionnel.

Utilisez :where() lorsque vous voulez cibler largement sans rendre les styles difficiles à surcharger. C'est le bon choix pour les resets, les styles de base, les conventions typographiques, les règles de layout peu intrusives ou les sélecteurs utilitaires.

:where(button, input, select, textarea) {
  font: inherit;
}

Cette règle est typique d'un reset léger : elle est utile, mais ne doit pas devenir un obstacle pour le reste de l'interface. Elle peut d'ailleurs être combinée avec les cascade layers pour placer les resets dans une couche volontairement faible.

Combiner avec :has(), @scope et le nesting

:is() et :where() deviennent encore plus puissants lorsqu'ils sont combinés avec d'autres fonctionnalités CSS modernes. Avec [:has()](/articles/css-has-selecteur-parent), on peut exprimer des conditions structurelles tout en gardant une syntaxe lisible.

.card:has(:is(img, video)) {
  display: grid;
  gap: 1rem;
}

La carte change de mise en page si elle contient une image ou une vidéo. Sans :is(), il faudrait dupliquer une partie du sélecteur.

Avec @scope, :where() permet de définir des règles locales faibles :

@scope (.dashboard-panel) {
  :scope :where(h2, p, ul) {
    margin-block: 0;
  }

  :scope :where(* + *) {
    margin-block-start: 0.75rem;
  }
}

Dans un code utilisant le nesting CSS natif, il faut rester attentif à la lisibilité. Le but n'est pas d'empiler toutes les nouveautés dans un même sélecteur, mais d'exprimer clairement l'intention.

.menu {
  & :where(a, button) {
    min-block-size: 2.75rem;
    padding-inline: 1rem;
  }

  & :is(a, button):focus-visible {
    outline: 2px solid currentColor;
    outline-offset: 3px;
  }
}

Ce code reste lisible : :where() définit la base faible, :is() regroupe les éléments interactifs pour un état précis.

Exemple TypeScript : générer des classes sans complexifier le CSS

Même dans une application React, Vue ou Nuxt, il est préférable de ne pas tout résoudre côté JavaScript. Les composants peuvent générer des classes simples, tandis que CSS gère les regroupements.

type ButtonTone = 'primary' | 'neutral' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

export function getButtonClass(tone: ButtonTone, size: ButtonSize) {
  return ['button', `button--${tone}`, `button--${size}`].join(' ');
}

Puis côté CSS :

.button:is(.button--primary, .button--danger) {
  color: white;
}

.button:where(.button--sm, .button--md, .button--lg) {
  border-radius: var(--radius-md);
}

.button--primary {
  background: var(--color-primary);
}

.button--danger {
  background: var(--color-danger);
}

Le TypeScript garde la responsabilité de produire des variantes valides. Le CSS garde la responsabilité de factoriser les règles visuelles.

Bonnes pratiques

Évitez de mettre des identifiants dans :is(), sauf cas très maîtrisé. Cela peut augmenter la spécificité de façon surprenante.

Préférez :where() pour les règles globales, les resets et les styles de structure. Cela rend vos composants plus faciles à surcharger.

Gardez les listes courtes. Si un :is() contient quinze sélecteurs, le problème est peut-être architectural plutôt que syntaxique.

N'utilisez pas :is() et :where() pour masquer une nomenclature confuse. Des classes bien nommées restent essentielles.

Enfin, testez vos règles dans les DevTools. L'inspecteur de styles reste le meilleur moyen de comprendre quelle règle gagne réellement dans la cascade.

Conclusion

:is() et :where() sont deux outils simples, mais très utiles pour écrire du CSS plus expressif. :is() réduit la répétition tout en conservant une spécificité normale. :where() permet de cibler largement sans ajouter de poids à la cascade.

Leur intérêt dépasse le confort syntaxique. Bien utilisés, ils aident à construire une architecture CSS plus souple, plus lisible et plus facile à faire évoluer. Dans un projet moderne, ils trouvent naturellement leur place aux côtés des cascade layers, des design tokens, de @scope, du nesting et des sélecteurs relationnels comme :has().