Barre de progression de lecture pour articles : CSS d’abord, JS minimal qui ne gâchera pas l’UX
Vous avez publié un bel article long. Puis les analytics indiquent que les lecteurs « quittent » au bout de 12 secondes. Peut‑être s’ennuient‑ils. Ou peut‑être sont‑ils perdus. Une petite barre de progression ne sauvera pas une écriture médiocre, mais elle évitera qu’un bon texte ressemble à un couloir sans issue.
Le piège : la plupart des barres de progression sont construites comme un jeune SRE qui a codé un cron job — ça marche dans le chemin heureux, ça fond sous charge, et ça ment calmement sur les cas limites. Construisons-en une qui se comporte dans de vrais navigateurs, sur de vrais téléphones, avec de réels budgets de performance.
Ce que vous construisez réellement (et pourquoi ça casse)
Une barre de progression de défilement ressemble à un ornement UI. Ce n’en est pas un. C’est un affichage de télémétrie en direct alimenté par l’un des signaux les plus fréquents du navigateur : le défilement. Si vous la branchez mal, vous n’obtenez pas seulement une barre légèrement inexacte. Vous obtenez :
- Du jank : des images ratées parce que vous forcez le layout à chaque tick de scroll.
- De la consommation de batterie : des mobiles qui font du travail supplémentaire pour une décoration.
- Une progression incorrecte : parce que vous avez mesuré la mauvaise chose (la hauteur du document n’est pas la hauteur de l’article).
- Des déplacements de mise en page : parce que votre barre change la mise en page au lieu d’être peinte.
- Des régressions d’accessibilité : parce que vous avez créé un état que les lecteurs d’écran ne peuvent pas interpréter, ou que vous avez masqué des contrôles en haut de la page.
L’astuce consiste à séparer les responsabilités comme dans un système de production :
- Mesure est la seule partie qui nécessite du JavaScript.
- Rendu est le travail du CSS : un élément fixe/sticky et une transformation/largeur basée sur une seule variable.
- Ordonnancement est l’endroit où naissent les bugs : vous voulez au maximum une mise à jour par frame, pas 80 par seconde parce que quelqu’un s’est emballé avec les événements de scroll.
Règle : traitez la progression de scroll comme une pipeline métrique. Taux d’échantillonnage, précision et coût comptent. Ne « mettez pas à jour le DOM pour faire simple ». C’est comme ça qu’on obtient une UI qui a l’air d’être en buffering.
Faits et un peu d’histoire (parce que le web adore répéter ses erreurs)
Un peu de contexte aide à prendre de meilleures décisions. Voici des points concrets expliquant pourquoi les UI de scroll « simples » partent souvent en sucette :
- Les événements de scroll avaient des fréquences très différentes selon les navigateurs ; beaucoup d’implémentations étaient « best effort » et couplées à la santé du thread principal.
- Les navigateurs mobiles ont introduit le défilement asynchrone (sur le thread du compositeur) pour rester fluides même quand JavaScript est occupé ; cela a rendu les effets naïfs pilotés par le scroll moins fiables.
- Les premiers widgets « progression de lecture » utilisaient souvent jQuery et
$(window).scroll(), ce qui encourageait les lectures/écritures de layout dans le même handler. Ça marchait — jusqu’à ce que le contenu devienne long. position: stickya mis des années à devenir banal entre les navigateurs ; avant cela, beaucoup de sites simulaient les en-têtes collants en JS, doublant le travail de scroll.- Le CLS (Cumulative Layout Shift) est devenu une métrique officielle à l’ère Web Vitals, forçant les équipes à se soucier des petits changements de layout — comme une barre de progression qui pousse le contenu vers le bas.
- IntersectionObserver a été introduit pour réduire la nécessité de sondages via scroll events pour la détection de visibilité. Ce n’est pas une panacée pour les barres de progression, mais c’est utile pour certaines mesures « article uniquement ».
- Les propriétés personnalisées CSS ont rendu pratique le passage d’une valeur numérique unique du JS vers plusieurs effets CSS sans toucher la structure DOM.
- Les unités de viewport dynamiques de Safari et le comportement de la barre d’adresse ont régulièrement surpris les équipes ; mesurer des plages de scroll en fonction de la taille du viewport est plus subtil qu’il n’y paraît.
Et oui, on fait toujours de l’UI pilotée par le scroll en 2025. La différence, c’est si vous le faites comme un adulte.
Exigences importantes en production
Si vous concevez cela pour une vraie publication, un wiki interne, un portail de documentation ou un site marketing avec de longs articles techniques, définissez les exigences en amont. Sinon votre « petite barre » deviendra une ferme à tickets de fiabilité.
Exigences fonctionnelles
- Mesure la progression à travers le contenu de l’article, pas le document entier (navigation, footer, commentaires, suggestions sont du bruit).
- Gère le contenu dynamique : images chargées tard, embeds qui s’agrandissent, blocs de code repliables, changements de polices.
- Fonctionne dans des conteneurs de défilement imbriqués si votre layout les utilise (courant dans les shells d’applis docs).
- N’empêche pas les interactions : le haut de la page contient souvent des contrôles, breadcrumbs, boutons retour. Votre barre ne doit pas intercepter les clics.
Exigences non fonctionnelles
- Travail minimal sur le thread principal : idéalement un calcul par frame pendant le scroll, et rien au repos.
- Pas de thrash de layout : évitez les patterns qui forcent un layout synchrone (lire le layout puis écrire puis relire en boucle).
- Mise en page stable : la barre doit superposer le contenu, pas le reflow (sauf si votre design réserve explicitement l’espace).
- Fallback gracieux : si JS échoue, la page doit rester lisible ; la barre peut simplement rester vide.
- Sémantique accessible : ne transformez pas l’UI en « spam ARIA », mais n’occultez pas un état significatif si vous le présentez comme information.
« idée paraphrasée » : Vous le construisez, vous l’exploitez — la responsabilité implique de se soucier de l’opérabilité, pas seulement de livrer des fonctionnalités.Werner Vogels (idée paraphrasée)
Architecture « CSS-first » : confiez le travail ennuyeux au CSS
Le CSS est bon pour deux choses qui comptent ici : le placement et le paint. Laissez‑le faire les deux. Votre élément de barre doit être idiot : un conteneur épinglé en haut et un enfant qui se remplit en fonction d’une seule valeur numérique.
Choisir le bon modèle de positionnement
Vous avez essentiellement deux choix raisonnables :
position: stickysur un wrapper en haut du document. Utile quand votre zone d’en‑tête participe à la mise en page et que vous voulez que la barre défile si l’en‑tête le fait.position: fixedpour une barre toujours visible. Utile quand vous voulez qu’elle soit immune aux subtilités d’overflow d’ancêtres et que vous ne vous souciez pas du contexte de mise en page.
Je choisis par défaut sticky quand la barre fait partie du chrome de l’article et que vous réservez sa hauteur. Je choisis fixed quand le site a des shells d’application compliqués, des transforms ou des règles d’overflow qui rendent sticky capricieux.
N’animez pas la mise en page si vous pouvez animer le paint
Vous verrez des implémentations qui font width: X%. Ce n’est pas automatiquement mauvais, mais ça peut provoquer plus de travail de layout que souhaité selon les contraintes environnantes. Une approche plus sûre est de rendre une barre pleine largeur et de la mettre à l’échelle :
- Définissez l’élément de remplissage sur
width: 100%. - Utilisez
transform: scaleX(var(--progress))avectransform-origin: left.
Les transforms sont généralement compatibles avec le compositeur. Généralement. Vous devez quand même mesurer et vérifier.
Opinion Utilisez transform: scaleX() sauf contrainte de design. C’est plus difficile de déclencher accidentellement un layout et plus simple à optimiser.
Empêchez la barre de voler les clics
Si votre barre superpose le haut, elle peut intercepter les clics sur les boutons de l’en‑tête. Ajoutez :
pointer-events: nonesur le shell de la progression, sauf si vous avez explicitement besoin d’interactivité.
C’est le type de bug qui passe en production parce que personne n’a cliqué sur le bouton « retour » en QA. Les utilisateurs le font. Toujours.
Exemple CSS (rendu seulement)
Nous avons déjà inclus une barre sticky sur cette page. L’essentiel est le contrat : le CSS lit une propriété personnalisée appelée --progress dans l’intervalle [0, 1]. Tout le reste est du style.
JS minimal : une tâche, une variable, pas de drame
Le rôle de JavaScript est de calculer la progression et de définir --progress. C’est tout. Pas de churn DOM. Pas d’innerHTML. Pas d’interrogation d’une douzaine d’éléments à chaque tick de scroll.
Ce que « progression » doit signifier
Il existe trois définitions courantes. Choisissez‑en une délibérément :
- Progression du document : combien vous avez défilé sur la page entière. Facile, mais trompeur si un gros footer ou une section de commentaires existe.
- Progression de l’article par top/bottom : 0% quand le haut de l’article atteint le haut du viewport ; 100% quand le bas de l’article atteint le bas du viewport (ou le haut). Ça correspond à « j’ai fini de lire ».
- Progression de l’article par ligne de vue : basée sur un marqueur (par exemple la rubrique courante). Plus difficile, mais utile dans la doc avec navigation.
Ce document se concentre sur la #2 parce qu’elle est honnête et stable.
Ordonnancement : requestAnimationFrame, pas le spam direct du scroll
Les événements de scroll peuvent se déclencher rapidement et de façon irrégulière. Si vous calculez et définissez le CSS à chaque événement, vous risquez d’effectuer du travail supplémentaire et d’imbriquer mal les lectures/écritures.
Pattern qui se comporte bien :
- Écouter le scroll (passif).
- Au scroll, planifier un seul
requestAnimationFramesi aucun n’est déjà planifié. - Dans rAF, lire ce qu’il faut, calculer la progression, écrire une variable CSS unique.
- Mettre aussi à jour au redimensionnement et lors de changements de contenu affectant le layout.
Implémentation JS minimale
Placez ceci en fin de body (ou dans un script différé). Il suppose que votre conteneur d’article est <main id="content"> ou un <article> plus spécifique que vous choisissez.
cr0x@server:~$ cat progress.js
(() => {
const root = document.documentElement;
const target = document.querySelector("main#content") || document.body;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const rect = target.getBoundingClientRect();
const viewport = window.innerHeight || root.clientHeight;
// Progress definition:
// 0 when target top is at top of viewport
// 1 when target bottom is at bottom of viewport
const total = rect.height - viewport;
let p;
if (total <= 0) {
// Content shorter than viewport: "done"
p = 1;
} else {
p = (-rect.top) / total;
}
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
// Passive scroll listener: don't block scrolling
window.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
// Update once on load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", compute, { once: true });
} else {
compute();
}
// Watch for layout changes that affect height (images, embeds, font swaps)
if ("ResizeObserver" in window) {
const ro = new ResizeObserver(requestTick);
ro.observe(target);
}
})();
C’est la barre « JS minimal ». Ce n’est pas zéro JS. C’est la bonne quantité de JS : une boucle de mesure, une écriture de variable CSS, un frame d’animation. Tout le reste nécessite justification.
Blague #1 : Si votre barre de progression nécessite une machine à états, vous ne suivez pas la lecture — vous construisez un programme spatial.
Pourquoi ce calcul fonctionne
getBoundingClientRect() vous donne le top de l’élément cible relatif au viewport. Quand vous descendez, rect.top devient négatif. Le dénominateur rect.height - viewport est la distance totale de défilement nécessaire pour que l’élément passe de « aligné en haut » à « aligné en bas ».
Cas limites gérés :
- Contenu court : si l’article tient dans le viewport, la progression vaut 1. Vous pouvez choisir 0 si vous préférez « pas de scroll = pas de progression », mais cela a tendance à sembler cassé.
- Surdéfilement / rebond : clampé dans
[0,1]pour que l’elastic scroll d’iOS n’affiche pas de progression négative. - Hauteur de contenu dynamique : ResizeObserver déclenche un recalcul quand la hauteur de l’article change.
Variantes : défilement du document, du conteneur et défilement « réel » de l’article
Variante A : progression du document entier (facile, souvent erronée)
Si votre page est essentiellement un article avec un petit footer, la progression sur tout le document est acceptable. Le calcul est simple : scrollTop divisé par (scrollHeight – clientHeight). C’est aussi celle qui mentira le plus quand vous ajoutez un panneau « Articles connexes » de la taille d’une nouvelle.
cr0x@server:~$ cat document-progress.js
(() => {
const root = document.documentElement;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const scrollTop = root.scrollTop || document.body.scrollTop;
const max = root.scrollHeight - root.clientHeight;
const p = max > 0 ? scrollTop / max : 1;
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
window.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
compute();
})();
Utilisez‑la quand votre layout est simple et stable. Sinon, mesurez l’article, pas l’univers.
Variante B : progression du conteneur de défilement (shells de docs)
De nombreux sites de documentation mettent la zone de lecture dans un conteneur qui défile tandis que la sidebar reste fixe. Les événements de window ne bougeront pas. Votre barre doit écouter le conteneur et mesurer son scrollTop.
Point d’attention : position: sticky et position: fixed se comportent différemment à l’intérieur de conteneurs overflow. Si votre shell d’appli utilise overflow: hidden sur le body et un div qui défile, préférez une barre attachée au conteneur qui défile, pas à la window.
cr0x@server:~$ cat container-progress.js
(() => {
const root = document.documentElement;
const scroller = document.querySelector("[data-scroll-container]");
if (!scroller) return;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? scroller.scrollTop / max : 1;
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
scroller.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
compute();
})();
Variante C : progression « réel article » avec offsets de début/fin
Parfois vous voulez que la progression commence après l’image hero, ou se termine avant l’inscription à la newsletter. Vous pouvez ajouter des éléments « sentinelles » — un au départ et un à la fin — et calculer la progression en fonction de leurs positions. C’est plus robuste que d’essayer de deviner des offsets avec des nombres magiques.
Les sentinelles sont aussi faciles à raisonner : vous pouvez les inspecter et les déplacer sans réécrire les maths.
Accessibilité et UX : la progression est une information
Une barre de progression est un indice visuel. Pour certains lecteurs, c’est aussi un outil décisionnel : « Ai‑je le temps de finir ? » Si vous la traitez comme purement décorative, c’est bien — alors gardez‑la aria-hidden et ne prétendez pas que c’est un contrôle.
Quand l’exposer aux technologies d’assistance
Si la barre est juste une fine ligne en haut, l’exposer comme rôle de progressbar mis à jour en live est en général du bruit. Les lecteurs d’écran n’ont pas besoin d’un flux constant « 32%, 33%, 34% » pendant que l’utilisateur défile. C’est comme un collègue qui narre votre trajet.
Meilleures options :
- Décorative uniquement : conservez
aria-hidden="true"sur l’élément de progression, comme dans l’exemple. - Exposer sur demande : fournissez un libellé « Progression de lecture : 42% » dans une barre d’outils qui se met à jour à faible fréquence (par exemple quand le scroll s’arrête) ou seulement quand elle est focalisée.
- L’utiliser pour la navigation : si vous fournissez aussi des contrôles « sauter à la section », alors la progression devient un composant UI légitime avec des sémantiques.
Couleur, contraste et « ne soyez pas mignon »
Une barre de progression n’est pas un arc‑en‑ciel. Votre travail principal est la lisibilité sur fonds clairs et sombres, et de ne pas entrer en conflit avec l’en‑tête. Utilisez une piste de fond subtile et une couleur de remplissage forte. Testez avec les modes de couleurs forcées si vous les supportez.
Respecter la réduction du mouvement
Une barre de progression n’est pas habituellement un problème de mouvement, mais certains designs ajoutent easing ou rebonds. Ne le faites pas. Un indicateur de scroll doit suivre le scroll. Si vous ajoutez du lag, vous créez de la défiance. Les utilisateurs détestent les compteurs malhonnêtes ; demandez à quiconque a regardé un spinner bloqué.
Modèle de performance : pourquoi les gestionnaires de scroll provoquent du jank
Le scroll paraît fluide quand le navigateur peut produire des frames à l’heure (environ 60fps sur beaucoup d’appareils, 120fps sur les plus récents). La mise à jour de votre barre de progression concourt avec tout le reste : recalcul de styles, layout, paint, script, décodage d’images, et ce que les scripts tiers ont décidé de faire aujourd’hui.
Les deux grands modes d’échec en performance
1) Layout synchrone forcé
Si votre handler lit le layout (comme getBoundingClientRect) après avoir écrit quelque chose qui invalide le layout (comme changer des largeurs ou des classes), le navigateur peut être obligé de forcer un flush de layout immédiatement. Ça peut arriver à chaque tick de scroll. Félicitations, vous avez inventé un générateur de jank.
2) Trop de mises à jour
Même si chaque mise à jour est « rapide », en faire 200 par seconde peut rester lent. requestAnimationFrame vous limite à une mise à jour par frame et laisse le navigateur ordonnancer le travail sensiblement.
Pourquoi le CSS‑first aide
En gardant le rendu dans le CSS et en écrivant une seule propriété personnalisée, vous réduisez les mutations DOM et l’invalidation de styles. Vous rendez aussi le code auditable. Quand un futur collègue ajoutera un querySelectorAll dans la boucle de scroll, il sera évident qu’il est sur le point de causer du tort.
Quid des « scroll-driven animations » CSS ?
Le CSS moderne propose des primitives d’animations liées au scroll dans certains navigateurs. Elles sont prometteuses, surtout parce qu’elles peuvent déplacer le travail hors du thread principal. Mais le comportement cross‑browser et les contraintes produit font qu’une approche JS minimale reste plus portable aujourd’hui.
Si vous pouvez utiliser de façon fiable les animations scroll‑linked pures dans l’ensemble de navigateurs supportés, faites‑le. Sinon, traitez‑les comme IPv6 : correctes, inévitables, et encore pleines d’écueils quand on s’y attend le moins.
Tâches pratiques : 12+ vérifications avec commandes, sorties et décisions
Vous voulez une barre de progression qui se comporte. Il faut tester comme un opérateur, pas comme un artiste démo. Ci‑dessous des tâches réelles avec commandes, sorties d’exemple, ce qu’elles signifient et la décision à prendre.
Task 1: Verify your HTML reserves no unexpected layout space
cr0x@server:~$ rg -n "progress-shell|bar-height|position: fixed|position: sticky" -S ./dist
dist/app.css:42:.progress-shell { position: sticky; top: 0; height: var(--bar-height); }
dist/index.html:12:<div class="progress-shell" aria-hidden="true">
Signification de la sortie : Vous confirmez comment la barre est positionnée et si elle participe à la mise en page. Sticky avec une hauteur explicite signifie que vous avez réservé de l’espace (pas de CLS dû à l’insertion). Fixed superpose souvent (pas d’impact layout) mais peut chevaucher l’UI de l’en‑tête.
Décision : Si vous voyez fixed sans tenir compte de l’en‑tête, ajoutez un padding top ou définissez pointer-events: none pour éviter de bloquer les interactions.
Task 2: Validate the CSS variable is being set at runtime
cr0x@server:~$ node -e "console.log('Check in DevTools: document.documentElement.style.getPropertyValue(\"--progress\")')"
Check in DevTools: document.documentElement.style.getPropertyValue("--progress")
Signification de la sortie : Ceci vous rappelle ce qu’il faut vérifier : la variable doit être définie sur documentElement (ou une portée connue) et être numérique.
Décision : Si elle est vide ou « NaN », votre JS n’a pas tourné, le sélecteur n’a pas matché, ou le calcul a divisé par zéro.
Task 3: Confirm your script is loaded with defer or at end of body
cr0x@server:~$ rg -n "<script.*progress(\.js)?|defer" ./dist/index.html
35:<script src="/assets/progress.js" defer></script>
Signification de la sortie : Utiliser defer évite de bloquer le parsing et garantit que le DOM existe au moment d’exécution.
Décision : Si ce n’est pas différé et que c’est dans le head, déplacez‑le ou ajoutez defer. L’UI de scroll ne doit pas retarder le premier rendu.
Task 4: Check for accidental scroll listeners added by frameworks or plugins
cr0x@server:~$ rg -n "addEventListener\\(\"scroll\"|onscroll|wheel\\)|IntersectionObserver" ./dist -S
dist/assets/progress.js:18:window.addEventListener("scroll", requestTick, { passive: true });
dist/assets/vendor.js:9912:window.addEventListener("scroll", onScroll);
Signification de la sortie : Il y a un autre écouteur de scroll dans le code vendor. Ça peut aller, ou être la vraie source du jank.
Décision : Auditez le handler supplémentaire. S’il lit le layout ou écrit des styles par événement, corrigez ou throttlez‑le. Ne blâmez pas la barre pour la faute d’un autre.
Task 5: Ensure scroll listeners are passive
cr0x@server:~$ rg -n "addEventListener\\(\"scroll\".*passive" ./dist/assets/progress.js
22:window.addEventListener("scroll", requestTick, { passive: true });
Signification de la sortie : Les écouteurs de scroll passifs indiquent au navigateur que vous n’appellerez pas preventDefault(), donc le scroll peut rester fluide.
Décision : Si passive manque, ajoutez‑le sauf si vous avez vraiment besoin d’annuler le scroll (vous n’en avez pas besoin pour une barre).
Task 6: Detect layout shifts caused by inserting the bar late
cr0x@server:~$ rg -n "document\\.createElement\\(|insertBefore\\(|prepend\\(" ./src -S
src/progress-init.js:4:document.body.prepend(shell);
Signification de la sortie : Vous injectez la barre dynamiquement. Cela cause souvent du CLS car ça change la mise en page après le paint.
Décision : Préférez un markup rendu côté serveur pour la barre ou réservez son espace en CSS avant l’injection. Si vous devez injecter, insérez un placeholder de hauteur identique tôt.
Task 7: Identify whether you’re measuring the correct element (article vs page)
cr0x@server:~$ rg -n "querySelector\\(\"(article|main|\\#content|\\.post)\"\\)" ./src/progress.js
3: const target = document.querySelector("main#content") || document.body;
Signification de la sortie : Cette mesure lie la progression à main#content. Si votre site englobe nav + footer dans main, la progression devient trompeuse.
Décision : Changez le sélecteur pour un vrai conteneur d’article (article, [data-article]) ou ajoutez des sentinelles de début/fin.
Task 8: Check that your progress bar doesn’t overlap interactive header elements
cr0x@server:~$ rg -n "pointer-events" ./dist/app.css
58:.progress-shell { position: sticky; top: 0; z-index: 999; height: var(--bar-height); background: var(--bar-bg); box-shadow: var(--shadow); }
Signification de la sortie : Aucun pointer-events: none trouvé.
Décision : Ajoutez pointer-events: none au shell sauf s’il est interactif. Cela évite les tickets « pourquoi je ne peux pas cliquer sur le logo ».
Task 9: Confirm that your CSS animation/transition isn’t lying
cr0x@server:~$ rg -n "transition:|animation:" ./dist/app.css
Signification de la sortie : Pas de transitions. Bien : la barre suit la position réelle sans délai.
Décision : Si vous trouvez des easing/transitions sur width/transform, supprimez‑les ou restreignez‑les. Les indicateurs de progression doivent être précis, pas cinématographiques.
Task 10: Find main-thread long tasks during scroll (quick local check)
cr0x@server:~$ node -e "console.log('Use Chrome DevTools Performance: record a scroll, then look for Long Task markers > 50ms in Main.')"
Use Chrome DevTools Performance: record a scroll, then look for Long Task markers > 50ms in Main.
Signification de la sortie : C’est l’invite opérateur : enregistrez, faites défiler, puis inspectez. Si le thread principal est bloqué, votre barre ne peut pas se mettre à jour en douceur.
Décision : Si vous voyez des longues tâches pendant le scroll, identifiez si elles viennent de votre handler, du rendu des polices, de la coloration syntaxique ou de scripts tiers. Corrigez le bloc le plus gros en premier.
Task 11: Check if images or embeds are changing article height after load
cr0x@server:~$ rg -n "<img |loading=|width=|height=" ./dist/index.html
88:<img src="/assets/hero.webp" loading="lazy">
Signification de la sortie : L’image est chargée paresseusement mais peut manquer d’attributs width/height. Cela peut provoquer des shifts de mise en page lors du chargement.
Décision : Ajoutez width/height (ou aspect-ratio en CSS) pour que la mise en page reste stable. Votre calcul de progression dépend de la hauteur de l’élément ; une hauteur instable rend la progression saccadée.
Task 12: Confirm ResizeObserver support or choose a fallback
cr0x@server:~$ node -e "console.log('If you support older browsers, gate ResizeObserver and also recompute on load + font load events.')"
If you support older browsers, gate ResizeObserver and also recompute on load + font load events.
Signification de la sortie : ResizeObserver est largement supporté, mais si vous avez une politique legacy stricte, il vous faut une stratégie de repli.
Décision : Sans ResizeObserver, mettez à jour sur load, resize, et peut‑être après le chargement des polices (ou acceptez de petites inexactitudes).
Task 13: Confirm your progress bar is not causing extra paints
cr0x@server:~$ node -e "console.log('In DevTools Rendering, enable Paint flashing and scroll. The bar should repaint cheaply, not trigger full-page flashes.')"
In DevTools Rendering, enable Paint flashing and scroll. The bar should repaint cheaply, not trigger full-page flashes.
Signification de la sortie : Le paint flashing révèle si vos mises à jour invalident de grandes régions.
Décision : Si tout l’en‑tête se repeint chaque frame, simplifiez les effets (supprimez box‑shadows/filters lourds) ou déplacez la barre sur sa propre couche (avec précaution ; les couches coûtent aussi en mémoire).
Task 14: Verify container-scroll implementations are measuring the right scroll root
cr0x@server:~$ rg -n "scrollTop|scrollHeight|clientHeight|data-scroll-container" ./src -S
src/container-progress.js:12: const max = scroller.scrollHeight - scroller.clientHeight;
Signification de la sortie : Vous utilisez les métriques de scroll du conteneur, pas de la window. Bon pour les shells d’app.
Décision : Si la progression ne change jamais, votre conteneur peut ne pas être la racine de scroll. Identifiez l’élément réellement défilant et attachez‑y le listener.
Guide de diagnostic rapide
Quand quelqu’un dit « la barre de progression est saccadée » ou « elle saute », ne débattez pas esthétique. Lancez un triage rapide comme pour un pic de latence.
Premier : confirmer la correction de la mesure
- L’élément mesuré est‑il le bon ? Inspectez le sélecteur et le bounding rect. Si vous mesurez le body entier alors que la lecture a lieu dans un conteneur imbriqué, vos chiffres sont faux.
- La plage de scroll est‑elle stable ? Si images/embeds chargent tard et changent la hauteur, la distance totale de scroll change en cours de lecture.
- La progression est‑elle clampée ? Le surdéfilement élastique peut produire des valeurs négatives ou supérieures à 1. Si la barre « s’enroule », vous avez oublié de clamp.
Second : trouver la classe bouchon
- Thread principal bloqué ? Cherchez des longues tâches pendant l’enregistrement du scroll. Si oui, la barre est innocente ; elle rapporte un système cassé.
- Thrash de layout ? Vérifiez si votre handler lit puis écrit le layout dans la même frame à répétition. Minimisez les lectures DOM, regroupez les écritures.
- Paint trop lourd ? Le paint flashing vous dira si votre barre déclenche des repaint coûteux. Les dégradés vont généralement ; les filtres et grosses ombres non.
Troisième : vérifier les bizarreries spécifiques au navigateur
- Barre d’adresse Safari / viewport dynamique ? Les sauts en haut/bas peuvent venir des changements de hauteur du viewport. Envisagez des mesures plus robustes et recomputez au
resize/ changement d’orientation. - Interaction overflow + sticky ? Si la barre disparaît ou colle mal, vérifiez l’
overflowdes ancêtres et les transforms.
Blague #2 : Une barre de progression de scroll est essentiellement un petit SLA : dès qu’elle ment, les utilisateurs ouvrent un ticket mental.
Erreurs courantes : symptômes → cause racine → correction
Voici la liste rugueuse. Si votre barre fait quelque chose d’étrange, c’est probablement l’un de ces cas.
1) La barre saute en arrière pendant le scroll
Symptômes : Vous descendez ; la barre diminue brièvement ou clignote.
Cause racine : La plage totale de scroll a changé en cours de défilement à cause d’images/embeds chargés, de polices swapées ou de composants repliables changeant de hauteur.
Correction : Réservez de l’espace pour les médias (width/height ou aspect-ratio). Ajoutez ResizeObserver (comme montré) et recalculer avec le dernier rect. Évitez de mesurer un conteneur qui reflowe lui‑même à cause d’en‑têtes sticky insérés tard.
2) La barre atteint 100% avant la fin de l’article
Symptômes : Vous atteignez 100% alors que vous lisez encore, surtout s’il y a un footer long ou un bloc « articles liés ».
Cause racine : Mesurer le mauvais élément — progression du document au lieu de celle de l’article.
Correction : Mesurez un conteneur d’article dédié ou utilisez des sentinelles de début/fin placées précisément où « la lecture commence/finit ».
3) La barre ne bouge jamais dans un layout docs app
Symptômes : Progression bloquée à 0% même en faisant défiler la zone de lecture.
Cause racine : Le scroll se produit dans un conteneur imbriqué, pas sur la window. Votre listener est sur window.
Correction : Attachez le listener au conteneur et calculez la progression avec scrollTop/scrollHeight/clientHeight.
4) La navigation supérieure devient inaccessible
Symptômes : Les utilisateurs ne peuvent pas cliquer sur les boutons de l’en‑tête près du bord supérieur ; ça fonctionne s’ils défilent un peu.
Cause racine : Un élément overlay fixe/sticky intercepte les événements pointer.
Correction : Ajoutez pointer-events: none au shell de progression ou repositionnez‑le pour éviter de chevaucher des éléments interactifs.
5) La performance du scroll s’effondre sur mobile
Symptômes : Les mises à jour de la progression laggent, le défilement est lourd, la batterie chauffe.
Cause racine : Écouteurs non passifs, trop de travail dans le handler, layouts forcés fréquents, ou paints lourds (filters, ombres).
Correction : Utilisez des listeners passifs, ordonnez via rAF, une écriture DOM par frame, évitez les effets visuels coûteux, et supprimez les écouteurs de scroll supplémentaires qui lisent le layout.
6) La barre est inexacte quand la barre d’adresse se replie/étend (mobile Safari)
Symptômes : La progression change sans scroller ou saute près du haut.
Cause racine : La hauteur du viewport change dynamiquement ; votre dénominateur dépend du viewport.
Correction : Recalculez au resize (déjà). Si c’est encore instable, utilisez un conteneur de scroll à hauteur stable ou traitez les petites deltas du viewport comme du bruit (debounce des updates resize).
7) Régression CLS quand la barre apparaît
Symptômes : Le contenu se décale après le chargement de la page.
Cause racine : L’élément barre est injecté après le paint initial sans espace réservé.
Correction : Rendre la barre dans le HTML initial, réserver sa hauteur, ou la superposer avec une position fixe pour qu’elle n’affecte pas le layout.
Trois mini‑histoires d’entreprise du pays du « ça marchait sur mon MacBook »
Mini‑histoire 1 : L’incident causé par une fausse hypothèse
Ils avaient une plateforme de contenu avec des articles longs et un redesign flamboyant. Quelqu’un a ajouté une barre de « lecture » liée à document.documentElement.scrollTop et a considéré l’affaire réglée. En QA, la barre avait belle allure. Les pages de test étaient courtes avec un footer factice.
Puis la production est arrivée. Les vraies pages avaient un système de commentaires qui se chargeait après le contenu principal, plus des cartes « liées » qui s’agrandissaient lorsqu’un variant A/B était activé. Les utilisateurs atteignaient 100% alors qu’ils n’avaient parcouru que la moitié de l’article, parce que le dénominateur (hauteur du scroll) changeait après que le calcul de progression ait supposé une valeur stable.
Les tickets support étaient précieux : « Votre site dit que j’ai fini de lire, mais ce n’est pas le cas. » Les gens ne déposent pas d’habitude des tickets à propos d’une fine ligne bleue. Ils l’ont fait parce que ça sapait la confiance. Et cela a aussi embrouillé les analytics internes qui utilisaient « 100% atteint » comme proxy de complétion.
La correction n’était pas compliquée, ce qui rendait la situation plus embarrassante. Ils ont commencé à mesurer le conteneur d’article réel, ajouté width/height aux images, et mis à jour la progression sur les changements de layout via ResizeObserver. La correction culturelle plus large fut : arrêter de supposer que le document est le contenu. Sur les sites modernes, le document est un tiroir fourre‑tout.
Mini‑histoire 2 : L’optimisation qui s’est retournée contre eux
Une autre équipe s’est prise de religion pour la performance et a décidé « d’optimiser » la barre en cachant les mesures. Ils ont calculé la hauteur de l’article une fois à DOMContentLoaded, l’ont stockée, et ont mis à jour la progression en utilisant cette constante. Ils ont même retiré l’appel coûteux à getBoundingClientRect() du chemin de scroll. Sur un laptop rapide, ça benchmarkait mieux. Tout le monde se sentait ingénieux.
Puis la police a chargé. Le texte a reflowé, les hauteurs de ligne ont changé, et la hauteur de l’article a augmenté. Sur certaines pages avec blocs de code, la coloration syntaxique s’est exécutée après le paint initial et a changé le layout. La barre a dérivé. À 80% de scroll, elle affichait 95%. À la fin, elle n’atteignait jamais 100%. Sur mobile, c’était pire car la hauteur du viewport changeait quand la barre d’URL se repliait, et leur dénominateur caché n’en tenait pas compte.
Ils ont « corrigé » en recalculant toutes les 250ms sur un timer, ce qui est rapidement devenu un métronome affamé de batterie. Le sprint suivant fut consacré à annuler l’optimisation et à la remplacer par un ResizeObserver + boucle d’update rAF — exactement l’approche qu’ils avaient initialement rejetée comme « trop lourde ».
Leçon : mettre en cache n’est pas une optimisation si la valeur sous‑jacente change. Ce n’est pas du caching ; c’est mentir avec assurance.
Mini‑histoire 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Une équipe docs a fait quelque chose peu sexy : ils ont écrit des critères d’acceptation pour la barre. Elle devait suivre la zone de lecture (pas la window), fonctionner avec des diagrammes embarqués, ne pas interférer avec les contrôles d’en‑tête, et se dégrader proprement si JS échouait.
Ils ont aussi défini un budget de performance : le travail de mise à jour devait rester sous une petite portion d’une frame sur un téléphone milieu de gamme. Ils n’ont pas deviné. Ils ont enregistré la performance durant le scroll avec DevTools sur des pages représentatives et gardé les traces dans un dossier de régression. Quand quelqu’un a modifié le CSS de l’en‑tête et a ajouté un flou lourd, le temps de paint a grimpé lors du scroll et la barre a commencé à saccader. La trace montrait clairement le coupable.
Parce que la barre était CSS‑first et JS‑minimale, la correction fut surtout de design : supprimer le flou, simplifier les ombres, isoler la couche de progression. La barre est restée une seule mise à jour de variable CSS. Pas de réécritures, pas de patchs nocturnes.
Les pratiques ennuyeuses — mesures, budgets, pages tests représentatives — évitent que de petites fonctionnalités UI ne deviennent des dettes opérationnelles permanentes.
Listes de contrôle / plan étape par étape
Si vous voulez un plan exécutable sans une semaine de réunions, le voici.
Checklist A: Build the component (CSS-first)
- Ajoutez un élément progress shell en haut du document (ou dans le conteneur de défilement).
- Stylez‑le avec
position: stickyoufixed. Choisissez intentionnellement. - Faites du fill un enfant qui se met à l’échelle selon
--progress. - Définissez
pointer-events: nonesauf si vous avez besoin d’interactivité. - Assurez‑vous que la hauteur de la barre est réservée si elle fait partie du layout (ou superposez‑la pour aucun effet de layout).
Checklist B: Wire the minimal JS update loop
- Choisissez la cible de mesure : élément article ou conteneur de scroll.
- Implémentez des updates ordonnancées par rAF ; ne mettez pas à jour directement à chaque événement de scroll.
- Clampez la progression dans
[0, 1]. - Écrivez une seule variable CSS sur
documentElement(ou une portée connue). - Mettez à jour sur
resizeet lors des changements de taille de contenu (ResizeObserver si possible).
Checklist C: Validate behavior on “real content”
- Testez un article court (tient dans le viewport) : la progression doit indiquer complet ou suivre la règle choisie.
- Testez un article avec beaucoup d’images et d’embeds : la progression ne doit pas reculer après le chargement des assets.
- Testez une page avec un footer énorme ou des contenus liés : la progression doit refléter l’article, pas la page.
- Testez sur mobile Safari : scrollez pour déclencher le repli/extension de la barre d’adresse ; surveillez les sauts.
- Testez la navigation clavier : la barre ne doit pas masquer les outlines de focus ou bloquer les contrôles.
Checklist D: Performance checks before you ship
- Enregistrez une trace de performance du scroll et cherchez des longues tâches > 50ms.
- Activez le paint flashing pour voir si la barre déclenche de grands repaints.
- Vérifiez qu’il n’y a qu’une boucle de mise à jour liée au scroll (ou plusieurs justifiées).
- Vérifiez les écouteurs passifs.
- Vérifiez l’absence de CLS dû à une injection tardive.
FAQ
1) Puis‑je faire une barre de progression sans JavaScript ?
Parfois, dans certains navigateurs, avec les animations CSS liées au scroll. Si vous avez besoin d’un comportement large et prévisible, le JS minimal reste le choix pragmatique. Zéro JS, c’est un bon titre clickbait, pas toujours un système stable.
2) Dois‑je utiliser width ou transform: scaleX() ?
Par défaut, utilisez transform: scaleX(). Il évite généralement le travail de layout et est plus fluide. Utilisez width si le design l’exige et après avoir vérifié que cela n’entraîne pas de reflow coûteux dans votre layout.
3) Pourquoi ne pas mettre à jour la barre directement dans l’événement de scroll ?
Parce que les événements de scroll peuvent se déclencher plus souvent que le budget d’une frame ne le supporte. rAF vous permet de mettre à jour au plus une fois par frame et regroupe lectures/écritures. C’est la différence entre échantillonner un signal et tenter de réagir à chaque électron.
4) Comment faire en sorte que la progression suive seulement l’article, pas les commentaires et contenus liés ?
Mesurez un conteneur d’article dédié, ou placez des sentinelles start/end autour du contenu que vous considérez comme « lecture ». Évitez la hauteur du document si vos pages ont des ajouts variables.
5) Quelle est la meilleure approche pour les SPA avec des panneaux de scroll imbriqués ?
Attachez votre listener au véritable conteneur de scroll et calculez la progression depuis son scrollTop et son scrollHeight. Le scroll de la window est souvent inopérant dans des shells SPA qui verrouillent le corps.
6) ResizeObserver cause‑t‑il des problèmes de performance ?
Il peut si vous observez trop d’éléments ou faites du travail lourd dans le callback. Ici, vous observez un élément et programmez simplement une mise à jour rAF. C’est léger et approprié. Évitez d’observer de gros sous‑arbres pour cette fonctionnalité.
7) Ma barre clignote sur des pages avec blocs de code repliables. Pourquoi ?
Parce que la hauteur du contenu change pendant que vous scrollez. Assurez‑vous que la progression recalcule après les expansions (ResizeObserver aide). Vérifiez aussi que le composant de bloc de code n’effectue pas de reflows coûteux pendant le scroll.
8) La barre doit‑elle montrer 0% en haut ou une petite valeur non nulle ?
Affichez 0% jusqu’à ce que l’article commence réellement. Si votre layout a une hero au‑dessus de l’article, définissez précisément le début (sentinelle) sinon les utilisateurs verront une « progression » avant d’avoir lu quoi que ce soit, ce qui ressemble à un compteur de carburant qui baisse alors que la voiture est à l’arrêt.
9) Comment éviter que la barre couvre l’ombre de mon header sticky ?
Décidez du layering : soit la barre fait partie du chrome de l’en‑tête (à l’intérieur), soit elle superpose au‑dessus. Ne laissez pas les guerres de z‑index se produire par accident. Attribuez un contexte de stacking clair.
10) Dois‑je débouncer les événements resize ?
Pas généralement si le travail de resize est léger et ordonnancé via rAF. Si vous voyez des tempêtes de resize (changement d’orientation, UI du viewport), vous pouvez ajouter un debounce simple, mais commencez par mesurer. Les suppositions créent des bugs.
Conclusion : prochaines étapes à déployer
Construisez la barre de progression comme vous construisez des systèmes fiables : contraignez les responsabilités, mesurez la vraie chose, et ne créez pas de travail inutile.
- Décidez ce que « progression » signifie pour votre produit (document vs article vs plage définie par sentinelles).
- Rendez la barre avec du CSS utilisant une seule propriété personnalisée
--progress. - Mettez à jour cette propriété avec du JS minimal : écouteur de scroll passif + rAF + clamp + ResizeObserver.
- Testez sur des pages avec du contenu réel (images chargées tard, embeds, longs blocs de code).
- Exécutez le guide de diagnostic rapide avant le lancement, puis conservez une trace de performance pour chasser les régressions.
Si vous procédez ainsi, la barre de progression redeviendra ce qu’elle doit être : un indicateur discret et honnête. Pas une source de nouveaux incidents. Votre rotation d’astreinte mérite au moins ça.