Cómo crear una barra de progreso de lectura
Cree una barra de progreso de lectura con JavaScript o animaciones CSS guiadas por scroll, con cálculo correcto, rendimiento y accesibilidad.
Una barra de progreso de lectura es un indicador fino y fijo (normalmente anclado a la parte superior del viewport) que se rellena del 0 % al 100 % a medida que el lector se desplaza por un artículo extenso.
La primera versión que publiqué llegaba al 100 % unas tres pantallas antes del final del post, porque estaba midiendo silenciosamente los comentarios y el pie de página junto con el artículo. Resulta que acertar con ese único detalle es la mayor parte del trabajo.
Puedes construirla de dos formas: con un listener de scroll en JavaScript que asigna el ancho de la barra a partir de un cálculo de porcentaje de desplazamiento, o con una animación CSS pura dirigida por el scroll, sin JavaScript en absoluto. Esta guía te ofrece ambas, las matemáticas correctas del scroll para barras con alcance de documento y con alcance de artículo, los detalles de rendimiento que mantienen económico el manejador de scroll, y el tratamiento de accesibilidad y mejora progresiva que necesitas antes de publicar.
Puntos clave
- Para una barra de todo el documento, el progreso del scroll es
scrollTop / (scrollHeight − clientHeight) × 100; para una barra que solo sigue el artículo, mide el<article>:window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100. - Usa la fórmula con alcance de artículo cuando la página tenga bloques de posts relacionados, comentarios o un pie de página alto, de modo que la barra alcance el 100 % al final del post y no al final de la página.
- Como
scrollse dispara en casi todos los frames, ejecuta la actualización del ancho dentro derequestAnimationFramey cachea las lecturas de altura, recalculando solo enresize, para que el manejador nunca fuerce un layout sincrónico. - La versión solo con CSS no necesita JavaScript: dale a una barra fija
animation-timeline: scroll(), un@keyframesque animetransformdescaleX(0)ascaleX(1), yanimation-duration: 1ms, que es lo que Firefox necesita para aplicar la animación en absoluto, detrás de su flag o en Nightly. - Las animaciones dirigidas por scroll están disponibles en Chrome/Edge 115+, Safari 26+ y Opera, pero todavía no son Baseline, porque el Firefox estable sigue ocultándolas detrás de un flag. Trata la barra solo con CSS como una mejora progresiva.
¿Qué es una barra de progreso de lectura y cuándo deberías usarla?
Una barra de progreso de lectura codifica visualmente «cuánto queda de este post» como una barra que crece a lo ancho de la parte superior de la pantalla. Encaja bien con contenido de formato largo (tutoriales profundos, ensayos, documentación) donde el lector se beneficia de una noción de posición que una barra de desplazamiento fina moderna ya no proporciona. En páginas cortas, una landing page o cualquier cosa que quepa en uno o dos viewports, añade ruido visual sin informar a nadie; ahí, sáltatela.
Dos decisiones de diseño determinan el resto de la implementación: qué región mide la barra (todo el documento o solo el cuerpo del artículo) y si la implementas en JavaScript o en CSS.
¿Cómo se calcula el progreso de lectura?
Discover how at OpenReplay.com.
Si las matemáticas están bien, todo lo demás se deduce. Hay dos fórmulas correctas según lo que quieras que represente la barra.
Scroll de todo el documento. Para una barra que se rellena mientras se desplaza la página completa, el progreso es la distancia desplazada dividida por la distancia máxima desplazable:
progress = scrollTop / (scrollHeight − clientHeight) × 100
El denominador resta la altura visible porque nunca puedes desplazar fuera de la vista el último viewport de contenido: se llega al final de la página cuando aún hay una pantalla completa visible. En el scroller raíz, scrollHeight es la altura total del contenido y clientHeight es la altura visible.
Scroll con alcance de artículo. Una barra de todo el documento cuenta tu pie de página, los comentarios y los bloques de posts relacionados, así que llega al 100 % al final de la página, no al final del post. Para arreglarlo, mide en su lugar el elemento <article>:
distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100
Aquí distance es la trayectoria de scroll desde el primer pintado hasta el momento en que el borde inferior del artículo entra en la vista. Usa la fórmula con alcance de artículo cuando tu página tenga algo sustancial por debajo del post; usa la fórmula de documento cuando el contenido desplazable sea la página completa. Ten en cuenta que offsetTop se mide respecto al ancestro posicionado más cercano, así que mantén el artículo en el flujo normal del documento para que el número signifique «distancia desde la parte superior de la página».
Implementación en JavaScript
El enfoque de JavaScript funciona en todos los navegadores y es la única forma de obtener un progreso preciso con alcance de artículo. Necesitas un elemento de barra fijo, un poco de CSS y un manejador de scroll.
<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);
Las mediciones se ejecutan en el manejador de load para que las imágenes y las fuentes ya se hayan estabilizado y clientHeight sea preciso. Sustituye la distance con alcance de artículo por la fórmula de documento si quieres una barra de página completa.
Mantener rápido el manejador de scroll
El evento scroll puede dispararse en casi cada frame de animación, así que un manejador ingenuo que lee el layout y escribe estilos en cada evento es una fuente segura de jank. Dos reglas lo mantienen económico.
Primero, agrupa la escritura visual dentro de requestAnimationFrame usando el flag ticking anterior, de forma que actualices la barra como máximo una vez por frame, con independencia de la frecuencia con la que se dispare scroll. Segundo, cachea tus lecturas de altura. Leer clientHeight/offsetTop en cada evento de scroll obliga al navegador a vaciar el layout pendiente, y esos reflows repetidos son la forma práctica del layout thrashing, así que calcula distance una vez y recalcúlala solo en resize. Un modo de fallo habitual en producción es exactamente este: un listener sin throttling que lee geometría y escribe width en cada evento, y los session replays de páginas con mucho scroll muestran con frecuencia las caídas de frames resultantes. Registrar el listener como { passive: true } también le indica al navegador que no vas a llamar a preventDefault, con lo que el desplazamiento se mantiene fluido.
La barra de progreso de lectura solo con CSS
Puedes construir la barra con cero JavaScript usando las animaciones CSS dirigidas por scroll. Vincula una animación a una línea de tiempo de scroll en lugar de al tiempo transcurrido, y el navegador dirigirá la escala horizontal de la barra desde la posición del scroll. Como la animación actúa sobre un transform y no sobre una propiedad de layout, puede ejecutarse en el compositor en lugar de pasar por un listener de scroll en el hilo 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 barra se maqueta a ancho completo y se comprime a nada con transform: scaleX(0), y luego se vuelve a escalar a medida que te desplazas. transform-origin: left es lo que hace que crezca desde el borde izquierdo y no desde el centro. Animar width en su lugar tendría un aspecto idéntico, pero forzaría el layout en cada frame, lo que devuelve la animación al hilo principal.
Hay dos detalles más que importan. Llamado sin argumentos, scroll() elige el ancestro desplazable más cercano y sigue su eje de bloque, lo que para la mayoría de los diseños de artículo de una sola columna significa el scroller raíz; pasa root si quieres nombrarlo explícitamente. Y Firefox se niega a aplicar la animación a menos que animation-duration sea distinto de cero, así que el habitual 1ms es lo que hace que funcione allí, y ese mismo valor mantiene la barra oculta en navegadores que carecen de soporte.
Ese último punto es el compromiso. animation-timeline no es Baseline. Está disponible en Chrome y Edge 115+, Safari 26+ y Opera, mientras que el Firefox estable todavía la mantiene detrás del flag layout.css.scroll-driven-animations.enabled y solo la activa por defecto en Nightly. La guarda @supports anterior es el contrato de mejora progresiva: los navegadores compatibles obtienen la barra CSS, los demás no renderizan nada, así que combínala con la versión en JavaScript como fallback si necesitas cobertura universal. Ten en cuenta también que la barra solo con CSS mide el contenedor de scroll completo, por lo que cuenta el pie de página y el contenido de comentarios igual que la fórmula JS con alcance de documento.
JavaScript frente a solo CSS: cuál usar
| Barra con JavaScript | Barra solo con CSS | |
|---|---|---|
| Soporte de navegadores | En todos | Chromium 115+, Safari 26+; Firefox detrás de un flag |
| Precisión con alcance de artículo | Sí | No, cuenta la página completa |
| Coste en el hilo principal | Listener de scroll | Ninguno por frame, el transform se ejecuta en el compositor |
| Requiere JavaScript | Sí | No |
Usa JavaScript cuando necesites que la barra se detenga al final del post o debas dar soporte a todos los navegadores; usa la barra solo con CSS cuando quieras un indicador de página completa con un código mínimo y puedas tratarla como una mejora.
Accesibilidad y acabado
Una barra de progreso es cromo decorativo, así que márcala con aria-hidden="true" para mantenerla fuera del árbol de accesibilidad, lejos de la salida de los lectores de pantalla y del orden de foco. Si de verdad quieres que se anuncie el valor, usa role="progressbar" con un aria-valuenow en vivo, aunque para la mayoría de los indicadores de lectura lo correcto es ocultarla. Envuelve la animación CSS en @media (prefers-reduced-motion: no-preference) para que los usuarios que renuncian al movimiento no reciban un elemento animado, y elige un color de barra con suficiente contraste respecto a tu cabecera para que siga siendo visible tanto en tema claro como oscuro.
Ambos enfoques producen el mismo resultado visible; la versión en JavaScript te da precisión con alcance de artículo y soporte universal, mientras que la versión solo con CSS te da una implementación más pequeña que mantiene su trabajo por frame fuera del hilo principal. Empieza por la que se ajuste a tus navegadores objetivo, conserva exactamente las matemáticas del scroll y el detalle de animation-duration: 1ms tal como se muestran, y combina ambas con @supports si quieres lo mejor de las dos.
Preguntas frecuentes
¿Por qué mi barra de progreso llega al 100 por ciento antes de que termine de leer el artículo?
La barra está midiendo el documento completo en lugar del artículo, así que cuenta tu pie de página, los comentarios y los bloques de posts relacionados dentro de la distancia desplazable. Cambia a la fórmula con alcance de artículo: calcula la distancia como (article.clientHeight + article.offsetTop) menos window.innerHeight, y luego divide window.scrollY por esa distancia. Entonces la barra llega al 100 por ciento al final del post y no al final de la página.
¿Por qué la barra de progreso solo con CSS funciona en Chrome pero no en Firefox?
Firefox mantiene las animaciones dirigidas por scroll detrás del flag layout.css.scroll-driven-animations.enabled en sus versiones estables, con la preferencia activada por defecto solo en Nightly, así que un Firefox sin el flag no renderiza nada. Aparte de eso, Firefox no aplicará la animación en absoluto a menos que animation-duration sea distinto de cero, que es la razón por la que 1ms es el valor que todo el mundo usa. Combina la barra CSS con una guarda at-supports y un fallback en JavaScript para tener cobertura completa.
¿La barra solo con CSS funciona sin un listener de eventos de scroll?
Sí. Las animaciones CSS dirigidas por scroll vinculan la animación a una línea de tiempo de scroll en lugar de al tiempo transcurrido, así que el navegador dirige el transform de la barra directamente desde la posición del scroll, sin listener de scroll en JavaScript y sin IntersectionObserver en el hilo principal. Animar un transform en lugar de width es lo que la mantiene amigable con el compositor: en Chromium y en Safari 26.4 o posterior la animación se ejecuta en el hilo del compositor, mientras que las versiones anteriores de Safari 26.x ejecutaban las animaciones dirigidas por scroll en el hilo principal. Animar width o height forzaría el layout en cada frame y devolvería el trabajo al hilo principal en todos los navegadores.
¿Debería exponerse una barra de progreso de lectura a los lectores de pantalla?
No, en la mayoría de los indicadores de lectura. Una barra de progreso es cromo decorativo, así que márcala con aria-hidden='true' para mantenerla fuera del árbol de accesibilidad, lejos de la salida de los lectores de pantalla y fuera del orden de foco. Solo si de verdad necesitas que se anuncie el valor deberías usar role='progressbar' con un atributo aria-valuenow en vivo, pero ocultar un indicador de lectura puramente visual es el comportamiento correcto por defecto.