So erstellen Sie einen Lesefortschrittsbalken
Erstellen Sie eine Lesefortschrittsleiste mit JavaScript oder CSS-Scroll-Animationen, mit korrekter Scroll-Math, Performance und Barrierefreiheit.
Ein Lesefortschrittsbalken ist eine dünne, fest positionierte Anzeige (meist am oberen Rand des Viewports verankert), die sich von 0 % auf 100 % füllt, während der Leser durch einen langen Artikel scrollt.
Die erste Version, die ich ausgeliefert habe, erreichte 100 % etwa drei Bildschirmhöhen vor dem Ende des Beitrags – weil sie stillschweigend die Kommentare und den Footer mitgemessen hat. Dieses eine Detail richtig hinzubekommen, macht letztlich den größten Teil der Arbeit aus.
Es gibt zwei Wege, so etwas zu bauen: ein JavaScript-Scroll-Listener, der die Breite eines Balkens aus einer Scroll-Prozentberechnung setzt, oder eine reine CSS-Lösung mit scroll-gesteuerter Animation, komplett ohne JavaScript. Dieser Leitfaden zeigt Ihnen beides, die korrekte Scroll-Mathematik für dokument- und artikelbezogene Balken, die Performance-Details, die den Scroll-Handler günstig halten, sowie die Barrierefreiheits- und Progressive-Enhancement-Aspekte, die Sie vor dem Deployment berücksichtigen müssen.
Die wichtigsten Erkenntnisse
- Für einen dokumentweiten Balken gilt: Scroll-Fortschritt =
scrollTop / (scrollHeight − clientHeight) × 100; für einen Balken, der nur den Artikel abbildet, messen Sie das<article>-Element:window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100. - Verwenden Sie die artikelbezogene Formel, wenn die Seite Blöcke mit verwandten Beiträgen, Kommentare oder einen hohen Footer enthält, damit der Balken am Ende des Beitrags 100 % erreicht und nicht erst am Seitenende.
- Da
scrollnahezu bei jedem Frame ausgelöst wird, führen Sie die Breitenaktualisierung innerhalb vonrequestAnimationFrameaus und cachen Sie die Höhenmessungen, die nur beiresizeneu berechnet werden – so erzwingt der Handler nie ein synchrones Layout. - Die reine CSS-Variante braucht kein JavaScript: Geben Sie einem fest positionierten Balken
animation-timeline: scroll(), eine@keyframes-Regel, dietransformvonscaleX(0)aufscaleX(1)animiert, sowieanimation-duration: 1ms– letzteres benötigt Firefox überhaupt, um die Animation anzuwenden, hinter dem entsprechenden Flag oder in Nightly. - Scroll-gesteuerte Animationen sind in Chrome/Edge 115+, Safari 26+ und Opera verfügbar, gehören aber noch nicht zur Baseline, da das stabile Firefox sie weiterhin hinter einem Flag versteckt. Behandeln Sie den reinen CSS-Balken daher als Progressive Enhancement.
Was ist ein Lesefortschrittsbalken, und wann sollten Sie ihn einsetzen?
Ein Lesefortschrittsbalken visualisiert „wie viel von diesem Beitrag noch übrig ist“ als Balken, der am oberen Bildschirmrand wächst. Er eignet sich für Langform-Inhalte (ausführliche Tutorials, Essays, Dokumentationen), bei denen Leser von einem Gefühl für die eigene Position profitieren – etwas, das moderne, dünne Scrollbalken nicht mehr vermitteln. Auf kurzen Seiten, einer Landingpage oder allem, was in ein oder zwei Viewports passt, erzeugt er nur visuelles Rauschen ohne Informationswert; dort sollten Sie darauf verzichten.
Zwei Design-Entscheidungen bestimmen den restlichen Aufbau: welchen Bereich der Balken messen soll (das gesamte Dokument oder nur den Artikeltext) und ob Sie ihn in JavaScript oder in CSS umsetzen.
Wie berechnet man den Lesefortschritt?
Discover how at OpenReplay.com.
Wenn die Mathematik stimmt, ergibt sich alles andere von selbst. Es gibt zwei korrekte Formeln, abhängig davon, was der Balken darstellen soll.
Dokumentweites Scrollen. Für einen Balken, der sich beim Scrollen der gesamten Seite füllt, ist der Fortschritt die gescrollte Distanz geteilt durch die maximal scrollbare Distanz:
progress = scrollTop / (scrollHeight − clientHeight) × 100
Der Nenner zieht die sichtbare Höhe ab, weil man den letzten Viewport voller Inhalt nie aus dem Blickfeld scrollen kann: Das Seitenende ist erreicht, während noch ein ganzer Bildschirm sichtbar ist. Beim Root-Scroller ist scrollHeight die Gesamthöhe des Inhalts und clientHeight die sichtbare Höhe.
Artikelbezogenes Scrollen. Ein dokumentweiter Balken zählt Ihren Footer, die Kommentare und die Blöcke mit verwandten Beiträgen mit und erreicht daher 100 % am Ende der Seite, nicht am Ende des Beitrags. Um das zu beheben, messen Sie stattdessen das <article>-Element:
distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100
distance ist hier die Scroll-Strecke vom ersten Paint bis zu dem Moment, in dem die Unterkante des Artikels ins Blickfeld gelangt. Verwenden Sie die artikelbezogene Formel, wenn Ihre Seite Substanzielles unterhalb des Beitrags enthält; nutzen Sie die Dokument-Formel, wenn der scrollbare Inhalt identisch mit der gesamten Seite ist. Beachten Sie, dass offsetTop relativ zum nächstgelegenen positionierten Vorfahrenelement gemessen wird – halten Sie den Artikel also im normalen Dokumentfluss, damit der Wert tatsächlich „Abstand vom Seitenanfang“ bedeutet.
JavaScript-Implementierung
Der JavaScript-Ansatz funktioniert in jedem Browser und ist der einzige Weg zu einem exakten artikelbezogenen Fortschritt. Sie brauchen ein fest positioniertes Balken-Element, etwas CSS und einen Scroll-Handler.
<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);
Die Messungen laufen im load-Handler, damit Bilder und Fonts bereits geladen sind und clientHeight korrekt ist. Tauschen Sie die artikelbezogene distance gegen die Dokument-Formel, wenn Sie einen seitenweiten Balken möchten.
Den Scroll-Handler performant halten
Das scroll-Event kann nahezu bei jedem Animationsframe feuern, weshalb ein naiver Handler, der bei jedem Event Layout-Werte liest und Styles schreibt, eine zuverlässige Quelle von Jank ist. Zwei Regeln halten ihn günstig.
Erstens: Bündeln Sie den visuellen Schreibvorgang mit dem oben gezeigten ticking-Flag in requestAnimationFrame, sodass Sie den Balken höchstens einmal pro Frame aktualisieren – unabhängig davon, wie oft scroll auslöst. Zweitens: Cachen Sie Ihre Höhenmessungen. Wenn Sie clientHeight/offsetTop bei jedem Scroll-Event lesen, zwingen Sie den Browser, ausstehendes Layout zu berechnen, und diese wiederholten Reflows sind genau das, was Layout-Thrashing in der Praxis bedeutet. Berechnen Sie distance daher einmal und nur bei resize neu. Ein häufiges Fehlermuster in Produktion ist genau dies: ein ungedrosselter Listener, der bei jedem Event Geometrie liest und width schreibt – Session Replays scroll-intensiver Seiten zeigen die daraus resultierenden Frame Drops regelmäßig auf. Den Listener als { passive: true } zu registrieren, signalisiert dem Browser außerdem, dass Sie preventDefault nicht aufrufen werden, sodass das Scrollen flüssig bleibt.
Der Lesefortschrittsbalken mit reinem CSS
Sie können den Balken mit CSS Scroll-driven Animations ganz ohne JavaScript umsetzen. Binden Sie eine Animation an eine Scroll-Timeline statt an vergangene Zeit, und der Browser steuert die horizontale Skalierung des Balkens anhand der Scroll-Position. Da die Animation eine transform-Eigenschaft und keine Layout-Eigenschaft betrifft, kann sie auf dem Compositor laufen, statt über einen Scroll-Listener im Main Thread zu gehen.
<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; }
}
}
}
Der Balken wird in voller Breite gerendert und mit transform: scaleX(0) auf null gestaucht, dann beim Scrollen wieder hochskaliert. transform-origin: left sorgt dafür, dass er von der linken Kante aus wächst und nicht aus der Mitte. width zu animieren würde identisch aussehen, aber bei jedem Frame ein Layout erzwingen, wodurch die Animation zurück auf den Main Thread wandert.
Zwei weitere Details sind wichtig. Ohne Argumente aufgerufen, wählt scroll() das nächstgelegene scrollende Vorfahrenelement und folgt dessen Blockachse – bei den meisten einspaltigen Artikel-Layouts also dem Root-Scroller; übergeben Sie root, wenn Sie ihn explizit benennen wollen. Und Firefox wendet die Animation nur an, wenn animation-duration ungleich null ist; die üblichen 1ms sind also das, was sie dort überhaupt laufen lässt – und derselbe Wert hält den Balken verborgen in Browsern ohne Unterstützung.
Genau dort liegt der Kompromiss. animation-timeline ist nicht Baseline. Es ist in Chrome und Edge 115+, Safari 26+ und Opera verfügbar, während das stabile Firefox es weiterhin hinter dem Flag layout.css.scroll-driven-animations.enabled hält und nur in Nightly standardmäßig aktiviert. Die @supports-Absicherung oben ist der Progressive-Enhancement-Vertrag: Unterstützende Browser bekommen den CSS-Balken, alle anderen sehen nichts – kombinieren Sie ihn also mit der JavaScript-Variante als Fallback, wenn Sie flächendeckende Abdeckung brauchen. Beachten Sie außerdem, dass der reine CSS-Balken den gesamten Scroll-Container messt und damit Footer- und Kommentarinhalte genauso mitzählt wie die dokumentbezogene JS-Formel.
JavaScript vs. reines CSS: Was sollten Sie verwenden?
| JavaScript-Balken | Reiner CSS-Balken | |
|---|---|---|
| Browser-Unterstützung | Überall | Chromium 115+, Safari 26+; Firefox hinter einem Flag |
| Artikelbezogene Genauigkeit | Ja | Nein, zählt die gesamte Seite |
| Kosten im Main Thread | Scroll-Listener | Keine pro Frame, das Transform läuft auf dem Compositor |
| JavaScript erforderlich | Ja | Nein |
Nutzen Sie JavaScript, wenn der Balken am Ende des Beitrags stoppen soll oder Sie jeden Browser unterstützen müssen; nutzen Sie den reinen CSS-Balken, wenn Sie eine seitenweite Anzeige mit minimalem Code wollen und sie als Enhancement behandeln können.
Barrierefreiheit und Feinschliff
Ein Fortschrittsbalken ist dekoratives Chrome, markieren Sie ihn daher mit aria-hidden="true", um ihn aus dem Accessibility Tree, der Screenreader-Ausgabe und der Fokusreihenfolge herauszuhalten. Wenn Sie den Wert tatsächlich vorgelesen haben möchten, verwenden Sie stattdessen role="progressbar" mit einem live aktualisierten aria-valuenow – bei den meisten Leseanzeigen ist Verbergen jedoch die richtige Wahl. Kapseln Sie die CSS-Animation in @media (prefers-reduced-motion: no-preference), damit Nutzer, die Bewegungsreduzierung aktiviert haben, kein animiertes Element erhalten, und wählen Sie eine Balkenfarbe mit ausreichendem Kontrast zu Ihrem Header, damit sie sowohl im hellen als auch im dunklen Theme sichtbar bleibt.
Beide Ansätze führen zum gleichen sichtbaren Ergebnis; die JavaScript-Variante liefert Ihnen artikelbezogene Genauigkeit und universelle Unterstützung, während die reine CSS-Variante eine schlankere Implementierung bietet, deren Arbeit pro Frame vom Main Thread fernbleibt. Beginnen Sie mit derjenigen, die zu Ihren Browser-Zielen passt, übernehmen Sie die Scroll-Mathematik und das Detail animation-duration: 1ms exakt wie gezeigt, und kombinieren Sie beide per @supports, wenn Sie das Beste aus beiden Welten wollen.
FAQs
Warum erreicht mein Fortschrittsbalken 100 Prozent, bevor ich den Artikel fertig gelesen habe?
Der Balken misst das gesamte Dokument statt nur den Artikel und zählt daher Footer, Kommentare und Blöcke mit verwandten Beiträgen zur scrollbaren Distanz hinzu. Wechseln Sie zur artikelbezogenen Formel: Berechnen Sie distance als (article.clientHeight + article.offsetTop) minus window.innerHeight und teilen Sie dann window.scrollY durch diese Distanz. Der Balken erreicht dann 100 Prozent am Ende des Beitrags und nicht am Ende der Seite.
Warum funktioniert der reine CSS-Fortschrittsbalken in Chrome, aber nicht in Firefox?
Firefox hält scroll-gesteuerte Animationen in seinen stabilen Releases hinter dem Flag layout.css.scroll-driven-animations.enabled, wobei die Einstellung nur in Nightly standardmäßig aktiv ist – ein Firefox ohne gesetztes Flag rendert daher nichts. Unabhängig davon wendet Firefox die Animation überhaupt nur an, wenn animation-duration ungleich null ist, weshalb alle den Wert 1ms verwenden. Kombinieren Sie den CSS-Balken für vollständige Abdeckung mit einer at-supports-Absicherung und einem JavaScript-Fallback.
Läuft der reine CSS-Balken ohne Scroll-Event-Listener?
Ja. CSS Scroll-driven Animations binden die Animation an eine Scroll-Timeline statt an vergangene Zeit, sodass der Browser das Transform des Balkens direkt aus der Scroll-Position steuert – ohne JavaScript-Scroll-Listener und ohne IntersectionObserver im Main Thread. Ein Transform statt width zu animieren ist es, was das Ganze compositor-freundlich hält: In Chromium und Safari 26.4 oder neuer läuft die Animation auf dem Compositor-Thread, während frühere Safari-26.x-Versionen scroll-gesteuerte Animationen stattdessen im Main Thread ausführten. width oder height zu animieren würde in jedem Browser bei jedem Frame ein Layout erzwingen und die Arbeit zurück in den Main Thread verlagern.
Sollte ein Lesefortschrittsbalken für Screenreader zugänglich sein?
Nein, bei den meisten Leseanzeigen nicht. Ein Fortschrittsbalken ist dekoratives Chrome, markieren Sie ihn daher mit aria-hidden='true', um ihn aus dem Accessibility Tree, der Screenreader-Ausgabe und der Fokusreihenfolge herauszuhalten. Nur wenn Sie den Wert wirklich vorgelesen haben müssen, sollten Sie stattdessen role='progressbar' mit einem live aktualisierten aria-valuenow-Attribut verwenden – eine rein visuelle Leseanzeige zu verbergen ist jedoch der richtige Standard.