Barra de progreso de lectura para artículos: CSS primero y JS mínimo que no afectará tu UX

¿Te fue útil?

Barra de progreso de lectura para artículos: CSS primero y JS mínimo que no afectará tu UX

Publicaste un precioso artículo largo. Luego, las analíticas dicen que los lectores “rebotan” después de 12 segundos. Quizá están aburridos. O quizá están perdidos. Una pequeña barra de progreso no arreglará una mala redacción, pero evitará que un buen texto parezca un pasillo interminable sin salidas.

La trampa: la mayoría de las barras de progreso se construyen como si un SRE junior programara un cron—funciona en el camino feliz, se derrite bajo carga y calla sobre los casos límite. Construyamos una que se comporte en navegadores reales, en teléfonos reales y con presupuestos reales de rendimiento.

Lo que realmente estás construyendo (y por qué se rompe)

Una barra de progreso de desplazamiento suena a adorno de UI. No lo es. Es una visualización de telemetría en vivo impulsada por una de las señales de mayor frecuencia en el navegador: el desplazamiento. Si lo conectas mal, no solo obtienes una barra ligeramente inexacta. Obtienes:

  • Jank: fotogramas perdidos porque fuerzas el layout en cada tick de scroll.
  • Consumo de batería: dispositivos móviles haciendo trabajo extra por una decoración.
  • Progreso incorrecto: porque mediste lo equivocado (la altura del documento no es la altura del artículo).
  • Desplazamiento de diseño: porque tu barra cambia el layout en vez de solo pintarlo.
  • Regresiones de accesibilidad: porque creaste estado que los lectores de pantalla no pueden interpretar o has ocultado controles en la parte superior.

El truco es separar responsabilidades como lo harías en un sistema de producción:

  • Medición es la única parte que necesita JavaScript.
  • Renderizado es trabajo de CSS: un elemento fijo/sticky y una transformación/anchura basada en una sola variable.
  • Programación es donde nacen los bugs: quieres una actualización por fotograma como máximo, no 80 por segundo porque alguien se emocionó con los eventos de scroll.

Regla: trata el progreso de scroll como una canalización de métricas. La tasa de muestreo, la precisión y el coste importan. No “simplemente actualices el DOM”. Así obtienes una UI que parece que está cargando.

Hechos y un poco de historia (porque la web ama repetir errores)

Algo de contexto ayuda a tomar mejores decisiones. Aquí puntos concretos que explican por qué la UI de desplazamiento “simple” suele fallar:

  1. Los eventos de scroll solían dispararse a tasas muy distintas entre navegadores; muchas implementaciones eran “mejor esfuerzo” y estaban acopladas a la salud del hilo principal.
  2. Los navegadores móviles introdujeron el desplazamiento asíncrono (scroll en el hilo del compositor) para sentirse fluidos incluso cuando JavaScript está ocupado; esto hizo que los efectos ingenuos dependientes del scroll sean menos fiables.
  3. Los primeros widgets de “progreso de lectura” usaban jQuery más $(window).scroll(), lo que fomentaba lecturas/escrituras de layout en el mismo manejador. Funcionaba—hasta que el contenido se alargó.
  4. position: sticky tardó años en estabilizarse entre navegadores; antes de eso muchos sitios simularon sticky con JS, lo que duplicaba el trabajo de scroll.
  5. CLS (Cumulative Layout Shift) se convirtió en una métrica formal en la era Web Vitals, obligando a equipos a preocuparse por pequeños cambios de layout—como una barra de progreso que empuja contenido hacia abajo.
  6. IntersectionObserver se introdujo para reducir la necesidad de sondeos de scroll para detección de visibilidad. No es una bala de plata para barras de progreso, pero es útil para algunas mediciones “solo artículo”.
  7. Las variables personalizadas de CSS hicieron práctico pasar un único valor numérico desde JS a múltiples efectos CSS sin tocar la estructura DOM.
  8. Las unidades de vista dinámica de Safari y el comportamiento de la barra de dirección sorprendieron repetidamente a equipos; medir rangos de desplazamiento basados en el tamaño del viewport es más sutil de lo que parece.

Y sí, seguimos haciendo UI dependiente del scroll en 2025. La diferencia es si lo haces como un adulto.

Requisitos que importan en producción

Si construyes esto para una publicación real, wiki interna, portal de documentación o sitio de marketing con artículos técnicos largos, define requisitos desde el principio. Si no, tu “pequeña barra” se volverá una granja de tickets de fiabilidad.

Requisitos funcionales

  • Mide el progreso dentro del contenido del artículo, no del documento entero (nav, pie de página, comentarios, enlaces relacionados son ruido).
  • Maneja contenido dinámico: imágenes que cargan tarde, embeds que se expanden, bloques de código que alternan, cambios de fuentes.
  • Funciona en contenedores de desplazamiento anidados si tu diseño los usa (común en shells de apps de documentación).
  • No bloquea la interacción: la parte superior suele tener controles, migas de pan, botones de retroceso. Tu barra no debe interceptar clics.

Requisitos no funcionales

  • Mínimo trabajo en el hilo principal: idealmente un cálculo por fotograma mientras se desplaza, y nada mientras está inactivo.
  • No thrash de layout: evita patrones que fuerzan layout síncrono (leer layout y escribir layout repetidamente).
  • Layout estable: la barra debe superponerse, no provocar reflow (a menos que el diseño explícitamente reserve espacio).
  • Fallback elegante: si JS falla, la página debe leerse igual; la barra puede simplemente estar vacía.
  • Semántica accesible: no generar “spam ARIA”, pero tampoco ocultar estado significativo si lo presentas como información.

“idea parafraseada”: Lo construyes, lo gestionas — la propiedad significa preocuparse por la operatividad, no solo por lanzar funciones.Werner Vogels (idea parafraseada)

Arquitectura CSS-first: haz que CSS haga el trabajo aburrido

CSS es bueno en dos cosas que importan aquí: posicionamiento y pintura. Déjalo encargarse de ambas. Tu elemento de barra de progreso debe ser tonto: un contenedor fijado en la parte superior y un hijo que se rellena visualmente según un único valor numérico.

Elige el modelo de posicionamiento correcto

Básicamente tienes dos opciones sensatas:

  • position: sticky en un envoltorio al inicio del documento. Bueno cuando tu área de encabezado participa en el layout y quieres que la barra se desplace si el encabezado lo hace.
  • position: fixed para una barra siempre visible. Bueno cuando la quieres inmune a quirks de overflow de ancestros y no te importa el contexto de layout.

Por defecto uso sticky cuando la barra es parte del chrome del artículo y reservas su altura. Prefiero fixed cuando el sitio tiene shells complicados, transforms o reglas de overflow que hacen que sticky se comporte como un gato temperamental.

No animes el layout si puedes animar la pintura

Verás implementaciones que usan width: X%. No es automáticamente malo, pero puede causar más trabajo de layout del que deseas según las restricciones circundantes. Un enfoque más seguro es renderizar una barra de ancho completo y escalarla:

  • Configura el elemento de relleno a width: 100%.
  • Usa transform: scaleX(var(--progress)) con transform-origin: left.

Las transformaciones suelen ser amigas del compositor. Suelen serlo. Aun así necesitas medir y verificar.

Opinión Usa transform: scaleX() salvo que tengas una razón de diseño en contra. Es más difícil activar layout accidentalmente y más fácil de optimizar.

Evita que la barra robe clics

Si tu barra se superpone en la parte superior, puede interceptar clics en botones del encabezado. Añade:

  • pointer-events: none en la concha de la barra, a menos que necesites interactividad explícita.

Este es el tipo de bug que se lanza porque nadie hace clic en el botón “volver” durante la QA. Los usuarios sí. Siempre.

Ejemplo CSS (solo renderizado)

Ya incluimos una barra sticky en esta página. La parte importante es el contrato: CSS lee una propiedad personalizada llamada --progress en el rango [0, 1]. Todo lo demás es estilo.

JS mínimo: un trabajo, una variable, sin drama

El trabajo de JavaScript es calcular el progreso y establecer --progress. Eso es todo. Sin churn del DOM. Sin innerHTML. Sin consultar una docena de elementos en cada tick de scroll.

Qué debe significar “progreso”

Hay tres definiciones comunes. Elige una deliberadamente:

  1. Progreso del documento: cuánto has avanzado por toda la página. Fácil, pero engañoso cuando hay un pie de página gigante o una sección de comentarios.
  2. Progreso del artículo por top/bottom: 0% cuando la parte superior del artículo llega a la parte superior del viewport; 100% cuando el fondo del artículo llega al fondo del viewport (o a la parte superior). Esto coincide con “he terminado de leer”.
  3. Progreso del artículo por línea de visión: basado en un marcador (por ejemplo, el encabezado actual). Más difícil, pero útil en documentación con navegación.

Este texto se centra en la #2 porque es honesta y estable.

Programación: requestAnimationFrame, no spam de scroll

Los eventos de scroll pueden dispararse rápido e irregularmente. Si calculas y estableces CSS en cada evento, corres el riesgo de hacer trabajo extra y de entrelazar lecturas/escrituras equivocadamente.

Patrón que se comporta:

  • Escucha el scroll (pasivo).
  • En scroll, programa un solo requestAnimationFrame si no está ya programado.
  • En rAF, lee lo necesario, calcula el progreso y escribe una variable CSS.
  • También actualiza en resize y en cambios de contenido que afecten el layout.

Implementación JS mínima

Coloca esto al final del body (o en un script con defer). Asume que tu contenedor de artículo es <main id="content"> o un <article> más específico que elijas.

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);
  }
})();

Esa es la barra “JS mínima”. No es cero JS. Es la cantidad correcta de JS: un bucle de medición, una escritura de variable CSS, un fotograma de animación. Cualquier otra cosa necesita justificación.

Broma #1: Si tu barra de progreso necesita una máquina de estados, no estás midiendo el progreso de lectura—estás construyendo un programa espacial.

Por qué este cálculo funciona

getBoundingClientRect() te da la posición superior del elemento objetivo relativo al viewport. Cuando te desplazas hacia abajo, rect.top se vuelve negativo. El denominador rect.height - viewport es la distancia total desplazable necesaria para que el elemento vaya de “alineado arriba” a “alineado abajo”.

Casos límite que maneja:

  • Contenido corto: si el artículo cabe en el viewport, el progreso es 1. Puedes elegir 0 si prefieres “sin scroll no hay progreso”, pero eso suele parecer roto.
  • Overscroll / rebote: acotado a [0,1] para que el rebote iOS no muestre progreso negativo.
  • Altura de contenido dinámica: ResizeObserver dispara recalculación cuando la altura del artículo cambia.

Variantes: desplazamiento del documento, del contenedor y “desplazamiento real del artículo”

Variante A: progreso de todo el documento (fácil, a menudo equivocado)

Si tu página es básicamente un artículo y un pie de página pequeño, el progreso del documento es aceptable. El cálculo es simple: scrollTop dividido por (scrollHeight – clientHeight). También es el que miente más cuando agregas un panel “Artículos relacionados” del tamaño de una novela.

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();
})();

Úsalo cuando tu layout sea simple y estable. De lo contrario, mide el artículo, no el universo.

Variante B: progreso en contenedor de scroll (shells de docs)

Muchos sitios corporativos de documentación ponen el panel de lectura en un contenedor con scroll mientras la barra lateral permanece fija. Los eventos de window no se moverán. Tu barra de progreso debe escuchar el contenedor y medir su scrollTop.

Truco clave: position: sticky y position: fixed se comportan distinto dentro de contenedores con overflow. Si tu app shell usa overflow: hidden en body y un div que hace scroll, prefiere una barra de progreso adjunta al contenedor que realmente hace scroll, no al 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: “artículo real” con offsets de inicio/fin

A veces quieres que el progreso empiece después del hero, o termine antes de la suscripción al boletín. Puedes añadir elementos “sentinela”: uno al inicio y otro al final, y calcular el progreso en función de sus posiciones. Esto es más robusto que adivinar offsets con números mágicos.

Las sentinelas también son fáciles de razonar: puedes inspeccionarlas y moverlas sin reescribir la matemática.

Accesibilidad y UX: el progreso es información

Una barra de progreso es una pista visual. Para algunos lectores también es una herramienta de decisión: “¿Tengo tiempo para acabar esto?”. Si la tratas como puramente decorativa, está bien—pero entonces mantenla aria-hidden y no pretendas que sea un control.

Cuándo exponerla a tecnología asistida

Si la barra es solo una línea fina en la parte superior, exponerla como un role de progressbar que se actualiza en vivo suele ser ruido. Los lectores de pantalla no necesitan un flujo constante de “32%, 33%, 34%” mientras el usuario se desplaza. Es como un compañero narrando tu desplazamiento.

Opciones mejores:

  • Solo decorativa: mantén aria-hidden="true" en el elemento de progreso, como en el ejemplo.
  • Exponer bajo demanda: proporciona una etiqueta de texto “Progreso de lectura: 42%” en una barra de herramientas que se actualice con baja frecuencia (p. ej., cuando el desplazamiento se detiene) o solo cuando tenga foco.
  • Úsala como parte de la navegación: si también ofreces controles “saltar a sección”, entonces el progreso se convierte en un componente de UI legítimo con semántica.

Color, contraste y “no te pongas creativo” en diseño

Una barra de progreso no es un arcoíris. Tu trabajo principal es la legibilidad contra fondos claros y oscuros, y no chocar con el encabezado. Usa una pista de fondo sutil y un color de relleno fuerte. Prueba con modos de colores forzados si los soportas.

Respeta la preferencia de reducción de movimiento

Una barra de progreso normalmente no es un tema de movimiento, pero algunos diseños añaden easing o rebotes. No lo hagas. Un indicador de scroll debe seguir el desplazamiento. Si agregas lag, generas desconfianza. A los usuarios les disgustan los medidores deshonestos; pregunta a cualquiera que haya visto un spinner bloqueado.

Modelo de rendimiento: por qué los manejadores de scroll provocan jank

El desplazamiento se siente fluido cuando el navegador puede producir fotogramas a tiempo (aprox. 60fps en muchos dispositivos, 120fps en los más nuevos). La actualización de la barra compite con todo lo demás: recálculo de estilos, layout, pintura, scripting, decodificación de imágenes y lo que el script de terceros haya decidido hacer hoy.

Los dos grandes modos de fallo de rendimiento

1) Layout síncrono forzado

Si tu manejador lee layout (como getBoundingClientRect) después de escribir algo que invalida el layout (como cambiar anchuras o clases), el navegador puede tener que vaciar el layout inmediatamente. Eso puede ocurrir por tick de scroll. Enhorabuena, has inventado un generador de jank.

2) Demasiadas actualizaciones

Aunque cada actualización sea “rápida”, hacerla 200 veces por segundo puede ser lento. requestAnimationFrame te limita a una actualización por fotograma y permite al navegador programar el trabajo sensiblemente.

Por qué ayuda el enfoque CSS-first

Al mantener el renderizado en CSS y escribir una sola propiedad personalizada, reduces mutaciones del DOM y la invalidación de estilos. También haces el código auditable. Cuando un futuro compañero añade un querySelectorAll extra en el bucle de scroll, es obvio que está a punto de causar daño.

¿Y las “animaciones vinculadas al scroll” en CSS?

El CSS moderno tiene primitivas de animación vinculadas al scroll en algunos navegadores. Son prometedoras, especialmente porque pueden mover trabajo fuera del hilo principal. Pero el comportamiento entre navegadores y las restricciones de producto aún hacen que un enfoque mínimo con JS sea más portable hoy.

Si puedes usar animaciones puras de CSS vinculadas al scroll de forma fiable en los navegadores que soportas, hazlo. Si no, trátalo como IPv6: correcto, inevitable y aún lleno de bordes afilados cuando menos lo deseas.

Tareas prácticas: 12+ comprobaciones con comandos, salidas y decisiones

Quieres una barra de progreso que se comporte. Eso significa probarla como un operador, no como un artista de demo. A continuación tareas reales con comandos, salidas de ejemplo, qué significan y la decisión que tomas.

Task 1: Verifica que tu HTML no reserve espacio de layout inesperado

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">

Significado de la salida: Confirmas cómo está posicionada la barra y si participa en el layout. Sticky con altura explícita significa que has reservado espacio (no CLS por inserción). Fixed suele superponerse (sin impacto en layout) pero puede solaparse con la UI del encabezado.

Decisión: Si ves fixed sin considerar el encabezado, añade padding-top o establece pointer-events: none para evitar bloquear interacciones.

Task 2: Valida que la variable CSS se establece en tiempo de ejecución

cr0x@server:~$ node -e "console.log('Check in DevTools: document.documentElement.style.getPropertyValue(\"--progress\")')"
Check in DevTools: document.documentElement.style.getPropertyValue("--progress")

Significado de la salida: Esto te recuerda qué comprobar: la variable debe establecerse en documentElement (o en un scope conocido) y debe ser numérica.

Decisión: Si está vacía o “NaN”, tu JS no se ejecutó, el selector no coincidió o la computación dividió por cero.

Task 3: Confirma que tu script se carga con defer o al final del body

cr0x@server:~$ rg -n "<script.*progress(\.js)?|defer" ./dist/index.html
35:<script src="/assets/progress.js" defer></script>

Significado de la salida: Usar defer evita bloquear el parseo y asegura que el DOM existe al ejecutarse.

Decisión: Si no está diferido y está en head, muévelo o añade defer. La UI de scroll no debe retrasar la primera renderización.

Task 4: Verifica listeners de scroll accidentales añadidos por frameworks o 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);

Significado de la salida: Hay otro listener de scroll en código vendor. Eso puede estar bien, o puede ser la verdadera fuente del jank.

Decisión: Audita el handler extra. Si lee layout o escribe estilos por evento, arréglalo o lo limites. No culpes a la barra por el crimen de otro.

Task 5: Asegúrate de que los listeners de scroll sean pasivos

cr0x@server:~$ rg -n "addEventListener\\(\"scroll\".*passive" ./dist/assets/progress.js
22:window.addEventListener("scroll", requestTick, { passive: true });

Significado de la salida: Los listeners de scroll pasivos indican al navegador que no llamarás preventDefault(), así que el desplazamiento puede permanecer fluido.

Decisión: Si falta passive, añádelo salvo que realmente necesites cancelar el scroll (no lo necesitas para una barra de progreso).

Task 6: Detecta shifts de layout causados por insertar la barra tarde

cr0x@server:~$ rg -n "document\\.createElement\\(|insertBefore\\(|prepend\\(" ./src -S
src/progress-init.js:4:document.body.prepend(shell);

Significado de la salida: Estás inyectando la barra dinámicamente. Eso suele causar CLS porque cambia el layout después del paint.

Decisión: Prefiere markup renderizado en el servidor para la barra o reserva espacio con CSS antes de inyectarla. Si debes inyectarla, inserta un marcador de mismo alto temprano.

Task 7: Identifica si mides el elemento correcto (artículo vs página)

cr0x@server:~$ rg -n "querySelector\\(\"(article|main|\\#content|\\.post)\"\\)" ./src/progress.js
3:  const target = document.querySelector("main#content") || document.body;

Significado de la salida: Esta medición ata el progreso a main#content. Si tu sitio envuelve nav + footer dentro de main, el progreso será engañoso.

Decisión: Cambia el selector a un contenedor real de artículo (article, [data-article]) o añade sentinelas de inicio/fin.

Task 8: Comprueba que tu barra no se superponga a elementos interactivos del encabezado

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); }

Significado de la salida: No se encontró pointer-events: none.

Decisión: Añade pointer-events: none a la concha salvo que sea interactiva. Esto previene tickets de “por qué no puedo clicar el logo”.

Task 9: Confirma que tu animación/transición CSS no engaña

cr0x@server:~$ rg -n "transition:|animation:" ./dist/app.css

Significado de la salida: Sin transiciones. Bien: la barra sigue la posición real del scroll sin retraso.

Decisión: Si encuentras transiciones de easing en width/transform, elimínalas o restringelas. Los indicadores de progreso deben ser precisos, no cinematográficos.

Task 10: Encuentra tareas largas en el hilo principal durante el scroll (comprobación rápida local)

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.

Significado de la salida: Es la indicación del operador: graba, desplázate y luego inspecciona. Si el hilo principal está bloqueado, tu barra no podrá actualizarse con fluidez.

Decisión: Si ves tareas largas durante el scroll, descubre si provienen de tu manejador, renderizado de fuentes, resaltado de sintaxis o scripts de terceros. Arregla el bloqueo más grande primero.

Task 11: Comprueba si imágenes o embeds cambian la altura del artículo tras cargar

cr0x@server:~$ rg -n "<img |loading=|width=|height=" ./dist/index.html
88:<img src="/assets/hero.webp" loading="lazy">

Significado de la salida: La imagen se carga de forma diferida pero puede no tener atributos width/height. Eso puede causar shifts cuando se carga.

Decisión: Añade width/height (o aspect-ratio en CSS) para que el layout sea estable. Tu cómputo de progreso depende de la altura del elemento; altura inestable significa progreso errático.

Task 12: Confirma soporte de ResizeObserver o elige un 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.

Significado de la salida: ResizeObserver está ampliamente soportado, pero si tienes una política estricta de legado, necesitas una estrategia de fallback.

Decisión: Sin ResizeObserver, actualiza en load, resize y quizá tras la carga de fuentes (o acepta pequeñas inexactitudes).

Task 13: Confirma que tu barra no causa repaints extra

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.

Significado de la salida: Paint flashing revela si tus actualizaciones invalidan grandes regiones.

Decisión: Si todo el encabezado se repinta cada frame, simplifica efectos (elimina sombras/filters pesados) o mueve la barra a su propia capa (con cuidado; las capas también consumen memoria).

Task 14: Verifica que las implementaciones con contenedores midan la raíz de scroll correcta

cr0x@server:~$ rg -n "scrollTop|scrollHeight|clientHeight|data-scroll-container" ./src -S
src/container-progress.js:12:    const max = scroller.scrollHeight - scroller.clientHeight;

Significado de la salida: Estás usando las métricas de scroll del contenedor, no las del window. Bueno para shells de apps.

Decisión: Si el progreso nunca cambia, tu contenedor puede no ser la raíz de scroll. Identifica el elemento que realmente hace scroll y adjunta allí.

Guía de diagnóstico rápido

Cuando alguien dice “la barra de progreso va lenta” o “salta”, no debatas estética. Haz un triage rápido como lo harías ante un pico de latencia.

Primero: confirma la corrección de la medición

  1. ¿Se mide el elemento correcto? Inspecciona el selector y el bounding rect. Si mide el body entero mientras la lectura ocurre en un contenedor anidado, tus números son basura.
  2. ¿Es estable el rango de desplazamiento? Si imágenes/embeds cargan tarde y cambian la altura, tu rango total cambia a mitad de lectura.
  3. ¿Está el progreso acotado? El rebote y el overscroll pueden producir valores negativos o >1. Si ves la barra “envolverse”, olvidaste acotar.

Segundo: encuentra la clase culpable del cuello de botella

  1. ¿Hilo principal bloqueado? Busca tareas largas durante la grabación del scroll. Si sí, tu barra es inocente; está reportando un sistema roto.
  2. ¿Thrash de layout? Comprueba si tu manejador lee layout y luego escribe layout en el mismo fotograma repetidamente. Minimiza lecturas DOM, agrupa escrituras.
  3. ¿Pintura demasiado pesada? Paint flashing te dice si la barra provoca repaints caros. Los gradientes suelen estar bien; filtros y sombras grandes a menudo no.

Tercero: comprueba rarezas específicas de navegador

  1. ¿Barra de dirección de Safari / viewport dinámico? Los saltos al inicio/final pueden ser cambios de altura del viewport. Considera mediciones más robustas y recalcula en resize/orientación.
  2. ¿Interacción overflow + sticky? Si la barra desaparece o se fija mal, revisa el overflow de ancestros y los transforms.

Broma #2: Una barra de progreso es básicamente un SLA pequeñito: el momento en que miente, los usuarios abren un ticket mental de incidente.

Errores comunes: síntomas → causa raíz → solución

Aquí está la lista dura. Si tu barra hace algo raro, probablemente es uno de estos.

1) La barra retrocede durante el desplazamiento

Síntomas: Desplazas hacia abajo; la barra disminuye brevemente o parpadea.

Causa raíz: El rango total de desplazamiento cambió a mitad de scroll debido a imágenes/embeds que cargaron, fuentes que cambiaron o componentes colapsables que alteraron la altura.

Solución: Reserva espacio para media (width/height o aspect-ratio). Añade ResizeObserver (como se mostró) y recalcula usando el rect más reciente. Evita medir un contenedor que esté reflujo por headers sticky insertados tarde.

2) La barra llega al 100% antes de que termine el artículo

Síntomas: Alcanzas 100% mientras aún lees, especialmente si el pie de página es alto o hay un bloque “relacionados”.

Causa raíz: Mides el elemento equivocado—progreso del documento en vez de progreso del artículo.

Solución: Mide un contenedor dedicado del artículo o usa sentinelas de inicio/fin colocadas exactamente donde comienza/termina la lectura.

3) La barra nunca se mueve en un layout de docs app

Síntomas: Progreso atascado en 0% aunque el panel de lectura se desplaza.

Causa raíz: El scroll ocurre en un contenedor anidado, no en window. Tu listener está en window.

Solución: Adjunta el listener al contenedor y calcula el progreso usando scrollTop/scrollHeight/clientHeight.

4) La navegación superior deja de ser clicable

Síntomas: Los usuarios no pueden pulsar botones del encabezado cerca del borde superior; funciona si hacen un pequeño scroll.

Causa raíz: Un elemento overlay fijo/sticky está interceptando eventos pointer.

Solución: Añade pointer-events: none a la concha o reubica la barra para evitar solapar elementos interactivos.

5) El rendimiento del scroll se hunde en móvil

Síntomas: Actualizaciones de progreso con retraso, scroll pesado, batería consumida más rápido.

Causa raíz: Listeners no pasivos, demasiado trabajo en el handler, layouts forzados frecuentes o pinturas pesadas (filters, sombras).

Solución: Usa listeners pasivos, programación con rAF, una escritura DOM por fotograma, evita efectos visuales caros y elimina listeners de scroll extra que hagan lecturas de layout.

6) La barra es inexacta cuando la barra de dirección colapsa/expande (mobile Safari)

Síntomas: El progreso cambia sin desplazamiento o salta cerca del inicio.

Causa raíz: La altura del viewport cambia dinámicamente; tu denominador depende de la altura del viewport.

Solución: Recalcula en resize (ya está). Si sigue saltando, usa un contenedor con altura estable o trata pequeñas deltas del viewport como ruido (desborda las actualizaciones de resize).

7) Regresión de CLS cuando aparece la barra

Síntomas: El contenido se mueve hacia abajo tras cargar la página.

Causa raíz: El elemento de la barra se inyecta después del paint inicial sin espacio reservado.

Solución: Renderiza la barra en el HTML inicial, reserva su altura o superponla con posición fixed para que no afecte el layout.

Tres mini-historias corporativas del mundo de “funcionaba en mi MacBook”

Mini-historia 1: El incidente causado por una asunción equivocada

Tenían una plataforma de contenidos con artículos largos y un rediseño brillante. Alguien añadió una barra de “progreso de lectura” ligada a document.documentElement.scrollTop y dio el trabajo por hecho. En QA, la barra se veía genial. Las páginas de prueba eran cortas con un pie de página simulado.

Luego llegó producción. Las páginas reales tenían un sistema de comentarios que cargaba tras el contenido principal, además de tarjetas “relacionadas” que se expandían cuando una variante de A/B se activaba. Los usuarios alcanzaban 100% de progreso mientras aún estaban a mitad del artículo, porque el denominador (altura de scroll) cambió después de que la calculadora de progreso asumiera un total estable.

Los tickets de soporte fueron memorables: “Su sitio dice que terminé de leer, pero no he terminado.” La gente no suele abrir tickets por una línea azul fina. Lo hicieron porque minó la confianza. También confundió a equipos de analítica interna que usaban “100% alcanzado” como proxy de finalización.

La solución no fue complicada, lo que lo hizo más embarazoso. Cambiaron a medir el contenedor real del artículo, añadieron width/height a imágenes y actualizaron el progreso en cambios de layout vía ResizeObserver. La corrección mayor fue cultural: dejar de asumir que el documento es el contenido. En sitios modernos, el documento es un cajón de basura.

Mini-historia 2: La optimización que salió mal

Otro equipo se volvió religioso con el rendimiento y decidió “optimizar” la barra cacheando mediciones. Calculaban la altura del artículo una vez en DOMContentLoaded, la almacenaban, y actualizaban el progreso usando esa constante. Incluso quitaron la costosa llamada a getBoundingClientRect() del camino de scroll. En un portátil rápido midieron mejoría. Todos se sintieron listos.

Entonces se cargó la fuente. El texto reflujo, cambiaron alturas de línea y la altura del artículo creció. En algunas páginas con bloques de código, el resaltado sintáctico también se ejecutó tras el paint inicial y cambió el layout. La barra de progreso derivó. Al 80% mostraba 95%. Al final no llegaba al 100%. En móvil fue peor porque la altura del viewport cambió cuando la barra de URL colapsó, y su denominador cacheado no lo contempló.

“Arreglaron” recalculando cada 250ms con un temporizador, lo que rápidamente se convirtió en un metrónomo que devoraba batería. La siguiente iteración fue revertir la “optimización” y reemplazarla por ResizeObserver + bucle rAF—exactamente el enfoque que habían descartado como “demasiado”.

Lección: cachear no es optimizar si el valor subyacente cambia. Eso no es cache, es mentir con confianza.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Un equipo de docs hizo algo no sexy: escribieron criterios de aceptación para la barra. Debía seguir el panel de lectura (no la ventana), funcionar con diagramas embebidos, no interferir con controles del encabezado y degradar graciosamente si JS fallaba.

También establecieron un presupuesto de rendimiento: el trabajo de actualización debía quedar dentro de una pequeña fracción de un fotograma en un teléfono de gama media. No lo adivinaron. Registraron el rendimiento del scroll con DevTools en páginas representativas y guardaron las trazas en una carpeta de regresiones. Cuando alguien cambió el CSS del encabezado y añadió un blur pesado, el tiempo de pintura se disparó durante el scroll y la barra empezó a tartamudear. La traza dejó al culpable claro.

Como la barra era CSS-first y JS-minimal, la solución fue mayormente de diseño: quitar el blur, simplificar sombras e aislar la capa de progreso. La barra siguió siendo una sola actualización de variable CSS. Sin reescrituras, sin parches nocturnos.

Prácticas aburridas—medición, presupuestos, páginas de prueba representativas—son las que evitan que pequeñas features de UI se conviertan en deuda operativa permanente.

Listas de verificación / plan paso a paso

Si quieres un plan que realmente puedas ejecutar sin una semana de reuniones, aquí está.

Checklist A: Construir el componente (CSS-first)

  1. Añade un elemento con la concha de progreso en la parte superior del documento (o dentro del contenedor de scroll).
  2. Estílalo con position: sticky o fixed. Elige uno intencionalmente.
  3. Haz que el relleno sea un elemento hijo que escale según --progress.
  4. Establece pointer-events: none salvo que necesites interactividad.
  5. Asegura que la altura de la barra esté reservada si forma parte del layout (o superponla si quieres cero efecto en layout).

Checklist B: Conectar el bucle JS mínimo de actualización

  1. Elige tu objetivo de medición: elemento article o contenedor de scroll.
  2. Implementa actualizaciones programadas con rAF; no actualices directamente por evento de scroll.
  3. Acota el progreso a [0, 1].
  4. Escribe una sola variable CSS en documentElement (o en un scope conocido).
  5. Actualiza en resize y en cambios de tamaño de contenido (ResizeObserver si está disponible).

Checklist C: Validar comportamiento con “contenido real”

  1. Prueba un artículo corto (cabe en viewport): el progreso debe marcar como completado o comportarse según tu regla elegida.
  2. Prueba un artículo con muchas imágenes y embeds: el progreso no debe retroceder tras cargar assets.
  3. Prueba una página con un pie de página enorme o contenido relacionado: el progreso debe reflejar el artículo, no la página.
  4. Prueba en mobile Safari: desplázate para disparar colapso/expansión de la barra de dirección; busca saltos extraños.
  5. Prueba la navegación por teclado: la barra no debe ocultar contornos de foco ni bloquear controles.

Checklist D: Comprobaciones de rendimiento antes de lanzar

  1. Graba una traza de rendimiento en scroll y busca tareas largas > 50ms.
  2. Activa paint flashing para ver si la barra provoca repaints grandes.
  3. Comprueba que solo hay un bucle de actualización impulsado por scroll (o que los múltiples están justificados).
  4. Verifica listeners de evento pasivos.
  5. Verifica que no haya CLS por inyección tardía.

Preguntas frecuentes

1) ¿Puedo hacer una barra de progreso sin JavaScript?

A veces, en algunos navegadores, usando animaciones CSS vinculadas al scroll. Si necesitas soporte amplio y comportamiento predecible, JS mínimo sigue siendo la opción pragmática. Cero JS es un titular bonito, no siempre un sistema estable.

2) ¿Debo usar width o transform: scaleX()?

Usa transform: scaleX() por defecto. Generalmente evita trabajo de layout y tiende a ser más suave. Usa width si el diseño lo requiere y has verificado que no desencadena reflow costoso en tu layout.

3) ¿Por qué no actualizar la barra directamente en el evento de scroll?

Porque los eventos de scroll pueden dispararse con más frecuencia de la que tu presupuesto de fotogramas puede tolerar. rAF te permite actualizar como máximo una vez por fotograma y agrupa lecturas/escrituras. Es la diferencia entre muestrear una señal y reaccionar a cada electrón.

4) ¿Cómo hago que el progreso siga solo el artículo, no comentarios y relacionados?

Mide un contenedor dedicado del artículo, o coloca sentinelas de inicio/fin alrededor del contenido que consideres “lectura”. Evita la “altura del documento” si tus páginas tienen apéndices variables.

5) ¿Cuál es el mejor enfoque para apps de una sola página con paneles de scroll anidados?

Adjunta tu listener al contenedor real de scroll y calcula el progreso a partir de su scrollTop y scrollHeight. El scroll de window a menudo no hace nada en shells SPA que bloquean el scrolling del body.

6) ¿ResizeObserver causa problemas de rendimiento?

PUEDE si observas demasiado o haces trabajo pesado en el callback. Aquí observas un elemento y simplemente programas una actualización rAF. Eso es barato y apropiado. Evita observar subárboles grandes para esta feature.

7) Mi barra parpadea en páginas con bloques de código colapsables. ¿Por qué?

Porque la altura del contenido cambia mientras te desplazas. Asegura que el progreso se recalcula tras expansiones (ResizeObserver ayuda). También asegúrate de que el componente del bloque de código no haga reflows costosos durante el scroll.

8) ¿La barra debe mostrar 0% en el inicio o un pequeño valor no nulo?

Muestra 0% hasta que el artículo realmente comience. Si tu layout tiene un hero encima del artículo, define el inicio con precisión (sentinela) o los usuarios verán “progreso” antes de leer nada, lo que se siente como un medidor de gasolina que cae estando parado.

9) ¿Cómo evito que la barra cubra la sombra de mi header sticky?

Toma una decisión sobre el apilamiento: o la barra es parte del chrome del header (dentro de él), o se superpone por encima. No dejes que las guerras de z-index ocurran por accidente. Asigna un contexto de apilamiento claro.

10) ¿Necesito debouncing en resize?

No suele ser necesario si el trabajo de resize es ligero y está programado vía rAF. Si ves tormentas de resize (cambios de orientación, UI del viewport), puedes añadir un debounce simple, pero empieza por medir. Adivinar es cómo se inventan bugs.

Conclusión: próximos pasos que puedes lanzar

Construye la barra de progreso como construyes sistemas fiables: limita responsabilidades, mide lo real y no crees trabajo donde no hace falta.

  1. Decide qué significa “progreso” para tu producto (documento vs artículo vs rango definido por sentinelas).
  2. Renderiza la barra con CSS usando una sola propiedad personalizada --progress.
  3. Actualiza esa propiedad con JS mínimo: listener de scroll pasivo + rAF + clamp + ResizeObserver.
  4. Prueba en páginas con contenido del mundo real (imágenes que cargan tarde, embeds, bloques de código largos).
  5. Ejecuta la guía de diagnóstico rápido antes del lanzamiento y guarda una traza de rendimiento para cazar regresiones.

Si lo haces así, la barra de progreso será lo que debe ser: un indicador discreto y honesto. No una fuente de nuevos incidentes. Tu rotación on-call merece al menos eso.


← Anterior
Correo: TTL durante la migración — el truco simple que evita la interrupción
Siguiente →
ZFS NFS: los ajustes que lo hacen sentirse como disco local

Deja un comentario