Comment créer une barre de progression de lecture
Créez une barre de progression de lecture avec JavaScript ou des animations CSS pilotées par le défilement, avec calcul, performance et accessibilité.
Une barre de progression de lecture est un indicateur fin et fixe (généralement épinglé en haut de la fenêtre d’affichage) qui se remplit de 0 % à 100 % à mesure que le lecteur défile dans un article long.
La première version que j’ai mise en production atteignait 100 % environ trois écrans avant la fin de l’article, parce qu’elle mesurait discrètement les commentaires et le pied de page en même temps que l’article. Il s’avère que régler correctement ce seul détail représente l’essentiel du travail.
Vous pouvez en construire une de deux façons : un écouteur de défilement en JavaScript qui définit la largeur d’une barre à partir d’un calcul de pourcentage de défilement, ou une animation CSS pure pilotée par le défilement, sans aucun JavaScript. Ce guide vous propose les deux approches, le calcul de défilement correct pour les barres à portée document et à portée article, les détails de performance qui maintiennent le gestionnaire de défilement peu coûteux, ainsi que la gestion de l’accessibilité et de l’amélioration progressive dont vous avez besoin avant la mise en production.
Points clés
- Pour une barre couvrant l’ensemble du document, la progression du défilement est
scrollTop / (scrollHeight − clientHeight) × 100; pour une barre qui suit uniquement l’article, mesurez l’élément<article>:window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100. - Utilisez la formule à portée article lorsque la page comporte des blocs d’articles connexes, des commentaires ou un pied de page volumineux, afin que la barre atteigne 100 % à la fin de l’article plutôt qu’au bas de la page.
- Comme l’événement
scrollse déclenche à presque chaque frame, exécutez la mise à jour de la largeur dansrequestAnimationFrameet mettez en cache les lectures de hauteur, en ne les recalculant qu’auresize, afin que le gestionnaire ne force jamais un calcul de mise en page synchrone. - La version en CSS pur ne nécessite aucun JavaScript : donnez à une barre fixe
animation-timeline: scroll(), un@keyframesqui animetransformdescaleX(0)àscaleX(1), etanimation-duration: 1ms, ce dont Firefox a besoin pour appliquer l’animation, derrière son drapeau ou dans Nightly. - Les animations pilotées par le défilement sont disponibles dans Chrome/Edge 115+, Safari 26+ et Opera, mais elles ne font pas encore partie du Baseline, car la version stable de Firefox les masque toujours derrière un drapeau. Traitez la barre en CSS pur comme une amélioration progressive.
Qu’est-ce qu’une barre de progression de lecture, et quand l’utiliser ?
Une barre de progression de lecture traduit visuellement « combien il reste de cet article » sous la forme d’une barre qui s’étend en haut de l’écran. Elle convient aux contenus longs (tutoriels approfondis, essais, documentation) où le lecteur bénéficie d’un repère de position que les barres de défilement fines modernes n’offrent plus. Sur des pages courtes, une page d’atterrissage, ou tout contenu qui tient dans une ou deux fenêtres d’affichage, elle ajoute du bruit visuel sans informer personne ; abstenez-vous dans ces cas-là.
Deux décisions de conception déterminent le reste de l’implémentation : quelle zone la barre mesure (l’ensemble du document ou seulement le corps de l’article), et si vous l’implémentez en JavaScript ou en CSS.
Comment calculer la progression de lecture ?
Discover how at OpenReplay.com.
Une fois le calcul juste, tout le reste en découle. Il existe deux formules correctes selon ce que vous voulez que la barre représente.
Défilement du document entier. Pour une barre qui se remplit à mesure que la page entière défile, la progression est la distance parcourue divisée par la distance de défilement maximale :
progress = scrollTop / (scrollHeight − clientHeight) × 100
Le dénominateur soustrait la hauteur visible car vous ne pouvez jamais faire défiler le dernier écran de contenu hors de vue : le bas de la page est atteint alors qu’un écran complet est encore visible. Sur le conteneur de défilement racine, scrollHeight correspond à la hauteur totale du contenu et clientHeight à la hauteur visible.
Défilement à portée article. Une barre couvrant tout le document prend en compte votre pied de page, les commentaires et les blocs d’articles connexes, si bien qu’elle atteint 100 % au bas de la page, et non au bas de l’article. Pour corriger cela, mesurez plutôt l’élément <article> :
distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100
Ici, distance correspond à la trajectoire de défilement depuis le premier affichage jusqu’au moment où le bord inférieur de l’article entre dans la vue. Utilisez la formule à portée article lorsque votre page comporte quoi que ce soit de substantiel sous l’article ; utilisez la formule document lorsque le contenu défilant est la page entière. Notez que offsetTop est mesuré par rapport à l’ancêtre positionné le plus proche : conservez donc l’article dans le flux normal du document pour que la valeur signifie bien « distance depuis le haut de la page ».
Implémentation en JavaScript
L’approche JavaScript fonctionne dans tous les navigateurs et constitue le seul moyen d’obtenir une progression précise à portée article. Il vous faut un élément de barre en position fixe, un peu de CSS et un gestionnaire de défilement.
<div id="progress-bar" aria-hidden="true"></div>
#progress-bar {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 4px;
background: linear-gradient(to right, #7b2ff7, #f107a3);
z-index: 9999;
}
const bar = document.getElementById("progress-bar");
const article = document.querySelector("article");
let distance = 0;
let ticking = false;
function measure() {
distance = (article.clientHeight + article.offsetTop) - window.innerHeight;
}
function update() {
const progress = Math.min((window.scrollY / distance) * 100, 100);
bar.style.width = `${progress}%`;
ticking = false;
}
function onScroll() {
if (!ticking) {
requestAnimationFrame(update);
ticking = true;
}
}
window.addEventListener("load", () => { measure(); update(); });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);
Les mesures sont effectuées dans le gestionnaire load afin que les images et les polices soient stabilisées et que clientHeight soit exact. Remplacez la distance à portée article par la formule document si vous souhaitez une barre couvrant toute la page.
Garder le gestionnaire de défilement rapide
L’événement scroll peut se déclencher à presque chaque frame d’animation : un gestionnaire naïf qui lit la mise en page et écrit des styles à chaque événement est donc une source fiable de saccades. Deux règles permettent de le maintenir peu coûteux.
Premièrement, regroupez l’écriture visuelle dans requestAnimationFrame à l’aide du drapeau ticking ci-dessus, afin de mettre à jour la barre au plus une fois par frame, quelle que soit la fréquence de déclenchement de scroll. Deuxièmement, mettez en cache vos lectures de hauteur. Lire clientHeight/offsetTop à chaque événement de défilement force le navigateur à vider les calculs de mise en page en attente, et ces reflows répétés sont précisément ce à quoi ressemble le layout thrashing en pratique : calculez donc distance une seule fois et ne la recalculez qu’au resize. Un mode de défaillance courant en production est exactement celui-là : un écouteur non limité qui lit la géométrie et écrit width à chaque événement ; les rejeux de session (session replays) de pages à fort défilement font fréquemment apparaître les pertes de frames qui en résultent. Enregistrer l’écouteur avec { passive: true } indique également au navigateur que vous n’appellerez pas preventDefault, ce qui préserve la fluidité du défilement.
La barre de progression de lecture en CSS pur
Vous pouvez construire la barre sans aucun JavaScript grâce aux animations CSS pilotées par le défilement. Liez une animation à une timeline de défilement plutôt qu’au temps écoulé, et le navigateur pilote l’échelle horizontale de la barre à partir de la position de défilement. Comme l’animation cible une propriété transform plutôt qu’une propriété de mise en page, elle peut s’exécuter sur le compositeur au lieu de passer par un écouteur de défilement sur le thread principal.
<div id="reading-progress" aria-hidden="true"></div>
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
#reading-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
z-index: 9999;
background: #7b2ff7;
transform: scaleX(0);
transform-origin: left;
animation-name: grow-progress;
animation-timeline: scroll();
animation-duration: 1ms; /* required so the animation runs in Firefox */
animation-timing-function: linear;
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-color-scheme: dark) {
#reading-progress { background: #fc0; }
}
}
}
La barre est disposée sur toute la largeur puis réduite à néant avec transform: scaleX(0), avant d’être remise à l’échelle au fil du défilement. C’est transform-origin: left qui la fait croître depuis le bord gauche plutôt que depuis le centre. Animer width à la place produirait un résultat identique mais forcerait un calcul de mise en page à chaque frame, ce qui ramènerait l’animation sur le thread principal.
Deux détails supplémentaires comptent. Appelée sans argument, scroll() sélectionne l’ancêtre défilant le plus proche et suit son axe de bloc, ce qui, pour la plupart des mises en page d’article en colonne unique, correspond au conteneur de défilement racine ; passez root si vous souhaitez le nommer explicitement. Et Firefox refuse d’appliquer l’animation si animation-duration n’est pas différent de zéro : c’est le 1ms habituel qui la fait fonctionner là-bas, et cette même valeur garde la barre invisible dans les navigateurs qui ne la prennent pas en charge.
Ce dernier point constitue le compromis. animation-timeline ne fait pas partie du Baseline. Elle est disponible dans Chrome et Edge 115+, Safari 26+ et Opera, tandis que la version stable de Firefox la maintient derrière le drapeau layout.css.scroll-driven-animations.enabled et ne l’active par défaut que dans Nightly. La garde @supports ci-dessus constitue le contrat d’amélioration progressive : les navigateurs compatibles affichent la barre CSS, les autres n’affichent rien ; associez-la donc à la version JavaScript en solution de repli si vous avez besoin d’une couverture universelle. Notez également que la barre en CSS pur mesure l’intégralité du conteneur de défilement : elle prend donc en compte le pied de page et les commentaires, tout comme la formule JS à portée document.
JavaScript ou CSS pur : que choisir ?
| Barre en JavaScript | Barre en CSS pur | |
|---|---|---|
| Prise en charge par les navigateurs | Partout | Chromium 115+, Safari 26+ ; Firefox derrière un drapeau |
| Précision à portée article | Oui | Non, elle prend en compte toute la page |
| Coût sur le thread principal | Écouteur de défilement | Aucun par frame, la transformation s’exécute sur le compositeur |
| JavaScript requis | Oui | Non |
Utilisez JavaScript lorsque la barre doit s’arrêter à la fin de l’article ou que vous devez prendre en charge tous les navigateurs ; optez pour la barre en CSS pur lorsque vous voulez un indicateur couvrant toute la page avec un minimum de code et que vous pouvez la considérer comme une amélioration.
Accessibilité et finitions
Une barre de progression est un élément décoratif : marquez-la donc avec aria-hidden="true" pour la maintenir hors de l’arbre d’accessibilité, à l’écart de la sortie des lecteurs d’écran et de l’ordre de focus. Si vous souhaitez réellement que la valeur soit annoncée, utilisez plutôt role="progressbar" avec un aria-valuenow mis à jour dynamiquement, mais pour la plupart des indicateurs de lecture, la masquer est le bon choix. Encapsulez l’animation CSS dans @media (prefers-reduced-motion: no-preference) afin que les utilisateurs qui refusent les animations n’aient pas d’élément animé, et choisissez une couleur de barre offrant un contraste suffisant avec votre en-tête pour qu’elle reste visible dans les thèmes clair et sombre.
Les deux approches produisent le même résultat visible ; la version JavaScript vous apporte une précision à portée article et une prise en charge universelle, tandis que la version en CSS pur vous offre une implémentation plus légère qui décharge le thread principal de son travail par frame. Commencez par celle qui correspond à vos cibles de navigateurs, conservez le calcul de défilement et le détail animation-duration: 1ms exactement tels que présentés, et combinez les deux avec @supports si vous voulez le meilleur des deux mondes.
FAQ
Pourquoi ma barre de progression atteint-elle 100 % avant que j'aie fini de lire l'article ?
La barre mesure l'ensemble du document au lieu de l'article : elle inclut donc votre pied de page, les commentaires et les blocs d'articles connexes dans la distance de défilement. Passez à la formule à portée article : calculez la distance comme (article.clientHeight + article.offsetTop) moins window.innerHeight, puis divisez window.scrollY par cette distance. La barre atteint alors 100 % au bas de l'article plutôt qu'au bas de la page.
Pourquoi la barre de progression en CSS pur fonctionne-t-elle dans Chrome mais pas dans Firefox ?
Firefox maintient les animations pilotées par le défilement derrière le drapeau layout.css.scroll-driven-animations.enabled dans ses versions stables, la préférence n'étant activée par défaut que dans Nightly : un Firefox sans ce drapeau n'affiche donc rien. Par ailleurs, Firefox n'applique pas du tout l'animation si animation-duration est nulle, ce qui explique pourquoi tout le monde utilise la valeur 1ms. Associez la barre CSS à une garde at-supports et à une solution de repli en JavaScript pour une couverture complète.
La barre en CSS pur fonctionne-t-elle sans écouteur d'événement de défilement ?
Oui. Les animations CSS pilotées par le défilement lient l'animation à une timeline de défilement plutôt qu'au temps écoulé : le navigateur pilote donc directement la transformation de la barre à partir de la position de défilement, sans écouteur de défilement JavaScript ni IntersectionObserver sur le thread principal. Animer une transformation plutôt qu'une largeur est ce qui la rend compatible avec le compositeur : dans Chromium et Safari 26.4 ou version ultérieure, l'animation s'exécute sur le thread du compositeur, alors que les versions antérieures de Safari 26.x exécutaient les animations pilotées par le défilement sur le thread principal. Animer width ou height forcerait un calcul de mise en page à chaque frame et ramènerait le travail sur le thread principal dans tous les navigateurs.
Une barre de progression de lecture doit-elle être exposée aux lecteurs d'écran ?
Non, pour la plupart des indicateurs de lecture. Une barre de progression est un élément décoratif : marquez-la avec aria-hidden='true' pour la maintenir hors de l'arbre d'accessibilité, à l'écart de la sortie des lecteurs d'écran et de l'ordre de focus. Ce n'est que si vous avez réellement besoin que la valeur soit annoncée que vous devriez utiliser role='progressbar' avec un attribut aria-valuenow mis à jour dynamiquement, mais masquer un indicateur de lecture purement visuel est le comportement par défaut approprié.