La barra de progreso de lectura en tres líneas
El ejemplo canónico resuelto entero: por qué scaleX y no width, el orden obligatorio de las declaraciones, y la versión accesible que no miente.
La barra de progreso de lectura es el “hola mundo” de las animaciones dirigidas por scroll, y por eso mismo circula en versiones que funcionan por casualidad. Vamos a construirla con las decisiones justificadas: qué propiedad se anima y por qué esa y no la obvia, en qué orden tienen que ir las declaraciones para que el atajo no las destruya, y qué se ve exactamente en el navegador que no implementa nada de esto.
- Escribir la barra de progreso completa y entender cada declaración.
- Justificar
transform: scaleX()frente awidthcon el pipeline de render. - Aplicar la regla de orden entre el atajo
animationyanimation-timeline. - Añadir la capa accesible sin romper la animación.
Las tres líneas y su justificación
@keyframes avanzar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progreso {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 4px;
background: oklch(70% 0.18 250);
transform-origin: left center;
animation: avanzar linear both; /* 1 */
animation-timeline: scroll(); /* 2 */
}
Dos líneas de lógica de animación y una tercera —transform-origin— que no es opcional. Vamos por partes.
scaleX y no width. La tentación es animar de width: 0% a width: 100%, que se lee mejor. Es una decisión cara: width dispara layout en cada frame, y layout es la etapa más costosa del pipeline. Con una barra de cuatro píxeles el reflow es pequeño, pero no es gratis y, sobre todo, obliga a que la animación viva en el hilo principal. transform no toca layout ni pintura: se resuelve en composición, y en Chromium y Safari una animación de scroll sobre transform puede correr fuera del hilo principal. Traducido: la barra sigue moviéndose con precisión mientras tu JavaScript está ocupado, que es exactamente el momento en que el usuario está scrolleando para huir de la página lenta.
transform-origin: left center. Sin ella, el origen por defecto es el centro, y scaleX(0.5) produce una barra centrada que crece hacia los dos lados. Se ve raro y nadie lo entiende hasta que lo mira de cerca. Con left center, la barra crece desde el borde de inicio. Si el sitio tiene versiones en árabe o hebreo, la forma correcta es transform-origin: 0 50% combinada con el flujo lógico, o directamente cambiar el origen bajo :dir(rtl).
both en el fill-mode. Fuera del rango activo de la timeline, sin fill-mode, la animación no aplica valores y la barra vuelve a su transformación base, que es la identidad: barra completa. Con both, el primer keyframe se aplica antes del rango y el último después, así que en el 0% la barra está vacía y en el 100% llena, sin saltos en los extremos.
linear. Ya lo vimos: la curva la pone el usuario con el dedo. Cualquier easing sobre una timeline de scroll introduce un desfase entre dónde está el scroll y dónde está la barra, y una barra de progreso que no coincide con el progreso es peor que no tener barra.
El orden que se rompe solo
animation-timeline está incluida en el atajo animation como valor de solo reseteo. Esto quiere decir dos cosas a la vez, y la segunda es la que muerde. Primero: no puedes darle un valor a través del atajo, no hay hueco en la gramática para ello. Segundo: escribir animation resetea cualquier animation-timeline declarada antes al valor inicial, que es auto, es decir, la timeline del documento.
La consecuencia es una regla dura: animation-timeline tiene que ir después de cualquier atajo animation que afecte al mismo elemento.
/* ROTO: el atajo resetea la timeline a auto */
.progreso {
animation-timeline: scroll();
animation: avanzar linear both;
}
/* CORRECTO */
.progreso {
animation: avanzar linear both;
animation-timeline: scroll();
}
Lo cruel del asunto es que el orden equivocado no falla: la animación se ejecuta, en el tiempo del documento, durante su duración por defecto. Como en el atajo sin tiempo la duración es la inicial, no se ve nada, y el diagnóstico natural es “la timeline no funciona” cuando lo que pasa es que hay una timeline distinta.
El caso realmente traicionero aparece cuando las dos declaraciones están en reglas separadas. Un animation: avanzar 400ms ease que vive en un fichero de utilidades, cargado después de tu componente, borra tu animation-timeline sin que ninguna de las dos reglas parezca tener nada que ver con la otra. Si trabajas con capas de cascada, la regla se agrava: lo que decide no es el orden de aparición en el fichero sino el orden de las capas.
Hay una variante de este fallo que parece un bug del navegador y no lo es. Escribes las dos declaraciones en el orden correcto, la barra funciona, y una semana después alguien añade animation-play-state: paused en un @media (prefers-reduced-motion: reduce) para “congelar las animaciones”. A partir de ahí la barra deja de moverse también para quien no ha pedido reducir el movimiento, si el navegador está en una sesión donde ese media query resolvió a true en algún momento. El problema de fondo es otro y es conceptual: prefers-reduced-motion no debe apagar una barra de progreso, porque una barra de progreso no es movimiento decorativo, es información de estado. Pausarla la deja mintiendo. Lo que hay que reducir es el movimiento que no aporta datos: los paralajes, las entradas deslizantes, las rotaciones. La barra de progreso, la sombra que indica que hay más contenido y el indicador de posición en un carrusel son feedback, y quitarlos empeora la accesibilidad en lugar de mejorarla. La prueba de fuego para decidir: si al eliminar la animación el usuario pierde información que no está disponible de otra forma, no es decoración y no se apaga; como mucho, se sustituye por un cambio discreto sin desplazamiento.
La versión honesta con fallback
En el navegador que no implementa timelines de scroll, el CSS de arriba deja una barra al 100%: el @keyframes no se aplica, transform se queda en la identidad, y el usuario ve una línea de color completa en la parte superior. No rompe nada, pero miente: dice “has leído todo” desde el primer píxel.
Hay dos formas de resolverlo y conviene elegir a conciencia. La primera es no mostrar la barra donde no hay timeline:
.progreso {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 4px;
background: oklch(70% 0.18 250);
transform-origin: left center;
display: none;
}
@supports (animation-timeline: scroll()) {
.progreso {
display: block;
animation: avanzar linear both;
animation-timeline: scroll();
}
}
La segunda es dejar la barra visible pero convertirla en otra cosa —una línea decorativa fija— cuando no hay progreso que mostrar. Ninguna de las dos es “la correcta”: depende de si la barra forma parte de la identidad visual de la cabecera o solo está ahí para informar. Lo que sí es incorrecto es dejarla al 100% fingiendo.
Y falta la capa que casi todo el mundo se salta. Una barra de progreso es información, y una información puramente visual no existe para quien usa un lector de pantalla. La marca correcta no es un div decorativo:
<div class="progreso" role="progressbar" aria-label="Progreso de lectura"
aria-valuemin="0" aria-valuemax="100" aria-hidden="true"></div>
Aquí hay una tensión real que merece nombrarse: con CSS puro no puedes actualizar aria-valuenow, porque el CSS no escribe atributos. O aceptas que la barra es puramente decorativa y la marcas con aria-hidden="true" para que no anuncie un progreso falso, o necesitas JavaScript para mantener el atributo al día, y en ese momento ya tienes el listener que querías evitar. La decisión honesta en la mayoría de los sitios de contenido es la primera: la barra es decoración, el progreso real lo da la posición del scroll, que el lector de pantalla ya conoce por otros medios.
Convierte la barra global en una barra por sección: cada <section> publica su propia timeline con scroll-timeline-name, y una barra fija en la cabecera muestra el progreso dentro de la sección actual en lugar del progreso del documento. Necesitarás timeline-scope en un ancestro común y tendrás que decidir qué pasa en las transiciones entre secciones, cuando dos timelines están activas a la vez. Pista: no todas las respuestas son buenas, y descubrir por qué es el objetivo del reto.