Scroll-state queries, l'interface qui sait enfin qu'elle est sticky
Une proposition de 2013 demandait au CSS une pseudo-classe pour styler un élément sticky une fois collé. Une objection l'a bloquée le jour même. Chrome a livré la réponse en 2025, sous une forme qui garde la trace de ce blocage.

Le 27 août 2013, Ojan Vafai, développeur de Chromium, propose sur www-style, la liste de discussion du groupe de travail CSS du W3C, d'ajouter une pseudo-classe (opens in a new tab) pour les éléments en position: sticky collés à un bord. Un en-tête collé en haut de l'écran pourrait alors prendre une ombre ou une couleur de fond avec une seule règle CSS. Moins d'une heure plus tard, Tab Atkins, l'un des principaux rédacteurs des spécifications CSS, répond par une objection (opens in a new tab). La pseudo-classe créerait une circularité. Cette objection a bloqué l'idée pendant plus de onze ans.
La réponse est arrivée dans Chrome 133 (opens in a new tab) en février 2025, sous une forme différente. Les scroll-state queries permettent au CSS de lire quatre états que le navigateur calcule pendant le défilement. Un élément est-il collé ? Est-il aimanté à un point d'arrêt ? Son contenu peut-il encore défiler ? Dans quel sens la page a-t-elle défilé en dernier ? Vingt mois après leur sortie, elles apparaissent sur 0,005 % des pages chargées dans Chrome (opens in a new tab). C'est vingt-trois fois moins que les View Transitions entre documents (opens in a new tab), livrées huit mois plus tôt.
Leur syntaxe porte la marque de l'objection de 2013. Le CSS ne peut toujours pas styler l'élément collé lui-même, seulement ce qu'il contient. La contrainte surprend à la première lecture. Elle devient logique une fois le problème de départ compris.
Pourquoi le CSS ne pouvait pas savoir qu'un élément est collé
Un sélecteur décide des styles appliqués à un élément. Les styles décident de la mise en page. Un sélecteur qui dépendrait de la mise en page pourrait donc modifier la condition qui le fait correspondre. L'exemple le plus court tient en une règle.
.site-header {
position: sticky;
top: 0;
}
/* Hypothetical pseudo-class, never added to CSS */
.site-header:stuck {
position: static;
}La boucle qu'aurait créée une pseudo-classe :stuck
L'en-tête se colle, la règle s'applique et lui retire position: sticky. Il n'est plus collé, la règle cesse de s'appliquer. Il redevient sticky puis se colle de nouveau. Le navigateur n'a aucun état stable à afficher.
Interdire position dans une règle :stuck ne suffit pas, comme Tab Atkins le montrait dès 2013. Une autre propriété posée sous :stuck peut déclencher un second mécanisme qui modifie position à son tour. Il faudrait alors interdire une à une toutes les propriétés capables de produire cet effet, au point de rendre la pseudo-classe inutilisable.
La demande est revenue en 2017 sur le dépôt GitHub du groupe de travail. Le ticket qui réclamait cette pseudo-classe (opens in a new tab) a été fermé le jour de son ouverture, le 27 juillet 2017. Florian Rivoal, membre du groupe, y résumait la règle. Un sélecteur ne peut pas dépendre de la mise en page, sinon il pourrait la modifier jusqu'à ne plus correspondre avant de correspondre à nouveau, dans "une boucle infinie de contradictions". La réponse figure depuis dans la foire aux questions du groupe de travail (opens in a new tab), qui range :stuck avec :overflowing et :on-screen parmi les problèmes peu susceptibles d'être résolus en CSS.
Ce que le JavaScript faisait à la place
Faute de pseudo-classe, l'état collé se détectait en JavaScript. Eric Bidelman a décrit la méthode de référence en 2017 sur le blog des développeurs Chrome, en partant d'un constat. Le CSS ne fournit aucun événement pour savoir quand un élément devient sticky ou cesse de l'être (opens in a new tab). La technique place un élément invisible, appelé sentinelle, juste avant l'en-tête. IntersectionObserver, l'interface JavaScript qui signale quand un élément entre dans la zone visible ou en sort, surveille ensuite cette sentinelle.
const header = document.querySelector(".site-header");
const sentinel = document.createElement("div");
sentinel.setAttribute("aria-hidden", "true");
header.before(sentinel);
const observer = new IntersectionObserver(([entry]) => {
const isStuck = entry.boundingClientRect.top < 0;
header.classList.toggle("is-stuck", isStuck);
});
observer.observe(sentinel);Détecter l'état collé avec une sentinelle
Quand la sentinelle sort par le haut de l'écran, l'en-tête qui la suit est collé. Le principe fonctionne. Il oblige à ajouter un élément au document pour chaque élément sticky. La sentinelle doit être décalée dès que l'en-tête se colle à 20 pixels du bord plutôt qu'à zéro. Une classe posée en JavaScript doit enfin rester synchronisée avec des styles écrits en CSS. L'état n'existe qu'après l'exécution du script.
La méthode plus ancienne, encore répandue, écoute l'événement scroll et mesure la position de l'élément à chaque déplacement. Ce code tourne sur le fil d'exécution principal, celui qui traite aussi les clics et l'affichage. D'après la documentation MDN, un seul de ces écouteurs peut suffire à créer des problèmes de performance (opens in a new tab). Mesurer la position des éléments en boucle pendant le défilement correspond d'ailleurs au scénario décrit à propos du coût du rendu (opens in a new tab).
Le même schéma valait pour les trois autres états. Savoir quelle carte d'un carrousel est centrée, si un tableau large cache encore des colonnes à droite ou si le visiteur remonte la page demandait chaque fois un écouteur et une classe.
Le détour par les container queries
Les container queries ont ouvert une autre voie. Disponibles dans tous les navigateurs depuis février 2023 (opens in a new tab), elles appliquent des styles selon la taille d'un conteneur plutôt que celle de l'écran. Le 22 février 2021, dans un ticket (opens in a new tab) où Adam Argyle relançait l'idée de sélecteurs dépendant de la mise en page, Oriol Brufau suggère d'utiliser ce mécanisme à la place d'une pseudo-classe.
Miriam Suzanne, rédactrice de la spécification des container queries, ouvre en juin 2021 le ticket qui définit des requêtes sur l'état d'un conteneur (opens in a new tab). Le groupe reporte la question en octobre 2021. Il décide le 15 novembre 2023 d'ajouter scroll-state() à la spécification. La fonctionnalité figure aujourd'hui dans CSS Conditional Rules Level 5 (opens in a new tab). En 2025, Miriam Suzanne a justifié ce choix auprès d'un développeur qui regrettait la pseudo-classe. Les container queries donnent selon elle un niveau de séparation, de sorte que les changements faits à l'intérieur de la requête ne peuvent pas modifier son résultat (opens in a new tab).
Concrètement, l'élément qui porte l'état devient un conteneur. La requête interroge ce conteneur et ne peut appliquer de styles qu'à ses descendants. Le conteneur lui-même reste hors de portée. Aucune règle déclenchée par la requête ne peut donc lui retirer position: sticky.
La spécification ajoute un second garde-fou, temporel. Elle reconnaît que l'état de défilement peut provoquer des cycles de mise en page (opens in a new tab) et fige l'état de chaque conteneur à des moments précis de la boucle de rendu du navigateur. Tous les calculs de style et de mise en page utilisent cet instantané jusqu'au suivant. Un changement de style ne peut donc pas réévaluer la requête pendant que le navigateur calcule l'affichage en cours.
Ces deux garde-fous ont un coût dans le HTML. L'élément collé doit envelopper un enfant qui porte les styles visibles. Un en-tête écrit d'un seul bloc gagne un niveau d'imbrication.
Le support des navigateurs en octobre 2026
| Navigateur | État | Détail |
|---|---|---|
| Chrome et Edge | Livré | stuck, snapped et scrollable depuis la version 133 en février 2025. scrolled depuis la version 144 (opens in a new tab) en janvier 2026 |
| Safari | En cours d'implémentation | WebKit a ouvert le chantier (opens in a new tab) en avril 2026. L'évaluation de scrollable est intégrée depuis septembre (opens in a new tab). stuck, snapped et scrolled restent à faire |
| Firefox | Non commencé | Le ticket d'implémentation (opens in a new tab), ouvert en novembre 2024, n'est assigné à personne |
Le support des scroll-state queries par navigateur en octobre 2026
Le travail intégré au moteur WebKit n'est pas encore arrivé dans Safari. Aucune version publiée ne prend en charge les scroll-state queries selon les données de compatibilité de MDN (opens in a new tab). Mozilla et Apple ont été sollicités le 29 septembre 2023 pour donner leur position officielle. Les deux demandes restent sans réponse, côté Mozilla (opens in a new tab) comme côté WebKit (opens in a new tab).
Interop est l'accord annuel par lequel les éditeurs de navigateurs s'engagent sur une liste commune de fonctionnalités. Proposées pour Interop 2026, les scroll-state queries n'ont pas été retenues (opens in a new tab) en février 2026. Une nouvelle proposition (opens in a new tab) a été déposée en septembre pour l'édition 2027. Le statut Baseline reste "limited availability".
La spécification elle-même bouge encore. La condition scrolled s'appelait direction jusqu'à une décision de septembre 2025 (opens in a new tab). Tab Atkins propose depuis juillet 2026 de redécouper la valeur (opens in a new tab) scroll-state de container-type en trois mots-clés distincts.
Le repli dans les navigateurs sans scroll-state queries
Firefox et Safari ignorent container-type: scroll-state, une valeur qu'ils ne reconnaissent pas. Ils ne savent pas non plus évaluer la condition scroll-state(), ce qui laisse le bloc @container inactif. Le visiteur voit seulement les styles écrits hors de la requête.
Cette mécanique fixe une règle d'écriture. L'état affiché par défaut doit être celui qui reste acceptable sans la fonctionnalité. Un en-tête qui se cache pendant la descente se code visible par défaut et caché dans la requête. Sans support, il reste visible. Un bouton de retour en haut caché par défaut puis révélé par la requête ne s'afficherait en revanche jamais dans Firefox. Ce cas demande @supports, qui vérifie qu'une déclaration est prise en charge avant d'appliquer les règles qu'il contient. C'est la logique de l'amélioration progressive (opens in a new tab), appliquée ici à chaque règle.
Un header sticky qui change d'apparence une fois collé
.site-header {
position: sticky;
top: 0;
container-type: scroll-state;
}
.header-bar {
transition: background-color 200ms, box-shadow 200ms;
}
@container scroll-state(stuck: top) {
.header-bar {
background-color: var(--surface-raised);
box-shadow: 0 2px 12px rgb(0 0 0 / 0.12);
}
}Une ombre sur l'en-tête collé
.site-header porte la position collante et devient le conteneur. .header-bar, son enfant direct, reçoit les styles. La requête remonte jusqu'au conteneur scroll-state le plus proche parmi les ancêtres de .header-bar et applique les règles quand il est collé au bord supérieur. Sans support, l'en-tête garde son apparence normale. La règle de repli est respectée sans avoir besoin de @supports.
Deux précisions de la documentation MDN (opens in a new tab) évitent des erreurs. La condition stuck ne s'évalue que sur un conteneur en position: sticky placé dans une zone qui défile. La requête stuck: none est vraie même sans position: sticky, elle ne prouve donc pas que le mécanisme fonctionne. Un élément collé dans un coin peut par ailleurs satisfaire deux bords à la fois, top et left par exemple. Quand plusieurs conteneurs s'emboîtent, container-name permet de viser le bon avec une requête comme @container sticky-header scroll-state(stuck: top).
Un bouton de retour en haut qui apparaît après le début du défilement
La condition scrollable teste si un conteneur peut encore défiler dans une direction donnée. scrollable: top est vraie dès que le visiteur a quitté le haut de la zone, puisqu'il peut y remonter. Posée sur l'élément html, elle interroge la page entière. Le groupe de travail a précisé en mai 2025 (opens in a new tab) que la requête porte alors sur la fenêtre visible, puisque l'élément racine n'a jamais de barre de défilement propre.
@supports (container-type: scroll-state) {
html {
container-type: scroll-state;
}
.back-to-top {
translate: 0 200%;
transition: translate 250ms ease-out;
}
@container scroll-state(scrollable: top) {
.back-to-top {
translate: 0 0;
}
}
}Cacher le bouton seulement là où la requête peut le révéler
Le bouton n'est caché que dans les navigateurs capables de le faire réapparaître. Ailleurs, il reste affiché en permanence, comme sur un site qui n'utiliserait pas la fonctionnalité. La même condition sert aux tableaux larges sur mobile. scrollable: inline-end est vraie tant qu'il reste des colonnes à droite, ce qui permet d'afficher une ombre ou une flèche sur le bord concerné. Le visiteur sait alors qu'il peut faire défiler le tableau.
Une carte qui se met en avant quand elle s'aimante
Le scroll snap, l'aimantation du défilement sur des points d'arrêt définis en CSS, permet de construire un carrousel sans bibliothèque JavaScript. La condition snapped indique quel élément est la cible de cette aimantation.
.carousel {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.slide {
flex: 0 0 80%;
scroll-snap-align: center;
container-type: scroll-state;
}
.slide-card {
transition: opacity 200ms, scale 200ms;
}
@container not scroll-state(snapped: x) {
.slide-card {
opacity: 0.5;
scale: 0.92;
}
}Atténuer les cartes qui ne sont pas aimantées
Chaque diapositive devient un conteneur et sa carte intérieure reçoit les styles. Les cartes sont écrites à pleine opacité. La requête inversée par not atténue celles qui ne sont pas aimantées. Un navigateur sans support ne sait évaluer ni la condition ni sa négation, il affiche donc toutes les cartes normalement. Écrire la requête dans l'autre sens, cartes atténuées par défaut et mises en avant dans la requête, laisserait tout le carrousel grisé dans Firefox et Safari.
D'après MDN (opens in a new tab), l'évaluation suit l'événement scrollsnapchanging. Cet événement se déclenche dès que le navigateur choisit la prochaine cible d'aimantation, pendant le geste. La carte est donc mise en valeur avant même que le doigt soit levé. L'information reste en revanche visuelle. Un lecteur d'écran ne perçoit pas ce changement d'opacité. Le carrousel a toujours besoin de ses attributs d'accessibilité habituels pour annoncer la diapositive courante.
Un en-tête qui se cache pendant la descente
La condition scrolled, livrée dans Chrome 144 (opens in a new tab) le 13 janvier 2026, indique le sens du dernier défilement. Elle permet de coder en CSS l'en-tête qui disparaît quand le visiteur descend et réapparaît dès qu'il remonte, un comportement jusqu'ici construit avec un écouteur scroll.
html {
container-type: scroll-state;
}
.site-header {
position: fixed;
inset: 0 0 auto;
transition: translate 250ms ease-out;
}
@container scroll-state(scrolled: block-end) {
.site-header:not(:focus-within) {
translate: 0 -100%;
}
}
@media (prefers-reduced-motion: reduce) {
.site-header {
transition: none;
}
}Masquer l'en-tête quand le visiteur descend
block-end désigne la fin de l'axe de lecture vertical, soit le bas de la page dans une langue écrite à l'horizontale. L'en-tête est visible par défaut et masqué dans la requête. Sans support, il reste simplement affiché. La transition disparaît pour les personnes qui ont demandé à leur système de réduire les animations. L'en-tête change alors d'état sans glisser.
Deux comportements méritent attention. Le premier tient à la spécification, qui ne prévoit aucune remise à zéro (opens in a new tab). Après le premier défilement, l'état conserve la dernière direction. Un visiteur qui s'arrête au milieu d'un article après être descendu garde un en-tête masqué jusqu'à ce qu'il remonte. C'est le comportement attendu de ce motif. Il ne convient pas à une barre qui porte une action à garder visible, comme un bouton d'achat.
Le second concerne le clavier. Le critère 2.4.11 des WCAG 2.2, les règles d'accessibilité du W3C, exige au niveau AA qu'un élément qui reçoit le focus clavier ne soit pas entièrement masqué (opens in a new tab) par un contenu du site. Un visiteur qui navigue avec la touche Tab vers un lien du menu caché ne le verrait pas. Le sélecteur :not(:focus-within) laisse l'en-tête en place dès qu'un de ses liens reçoit le focus.
Scroll-state queries et scroll-driven animations
Les deux fonctionnalités réagissent au défilement et sont faciles à confondre. Les scroll-driven animations (opens in a new tab) font avancer une animation le long d'une timeline liée au défilement (opens in a new tab). La valeur évolue en continu, comme une barre de progression qui se remplit au fil de la lecture. Les scroll-state queries sont des conditions vraies ou fausses qui basculent un ensemble de styles. La spécification déclare d'ailleurs chacune d'elles de type "discrete".
Le choix suit la nature de l'effet. Une valeur qui doit suivre la position exacte du défilement relève d'une animation pilotée par le défilement. Un changement d'apparence qui dépend d'un état relève d'une scroll-state query, avec une transition CSS ordinaire pour adoucir la bascule. Les deux se combinent sur un même composant. Une barre de progression peut se remplir en continu dans un en-tête qui prend son ombre une fois collé.
Ce que les scroll-state queries changent dans un projet
Les scroll-state queries remplacent une catégorie entière de petits scripts par des conditions que le navigateur évalue lui-même au bon moment de son rendu. Sentinelles, écouteurs scroll et classes ajoutées en JavaScript disparaissent du code. Le prix se limite à un élément d'enveloppe supplémentaire, hérité de la circularité qui a bloqué :stuck pendant plus de onze ans.
Le support réserve encore la fonctionnalité à l'enrichissement de l'interface. Chrome et Edge appliquent les quatre conditions, aucune version publiée de Safari n'en évalue une seule et Firefox n'a pas commencé. Écrire l'état par défaut comme celui qui reste correct sans support permet de s'en servir dès maintenant. Les visiteurs de Chrome et d'Edge obtiennent une interface qui réagit à son propre état. Les autres obtiennent la version qu'ils auraient eue de toute façon.