wandres.dev
SCROLL-DRIVEN II · scroll() y animation-timeline

Elegir el scroller y el eje

Los tres valores de scroller de la función scroll(), los cuatro ejes, cómo se resuelve cada uno y qué pasa cuando la resolución falla.

⏱ 16 min

scroll() acepta dos argumentos opcionales, sin orden fijo, y con eso decide de qué barra de desplazamiento vas a colgar la animación. Parecen dos palabras sueltas y son en realidad la parte del sistema donde más gente se atasca, porque la resolución depende del árbol de elementos y del modo de escritura, no de dónde esté el CSS. Entender las reglas de resolución ahorra las dos horas clásicas de depurar una animación que no se mueve.

🎯 Al terminar esta lección sabrás
  • Distinguir nearest, root y self y predecir a qué elemento resuelve cada uno.
  • Elegir entre block, inline, x e y sabiendo cuál depende del modo de escritura.
  • Diagnosticar por qué una scroll() no produce progreso.
  • Aplicar el caso de la galería horizontal, donde el eje por defecto es siempre el equivocado.

Los tres scrollers

La gramática es scroll( [ <scroller> || <axis> ]? ), donde <scroller> es root, nearest o self. El doble pipe significa que puedes poner uno, el otro, los dos, o ninguno, y en cualquier orden: scroll(block nearest) y scroll(nearest block) son la misma declaración. Los valores por defecto son nearest y block, así que scroll(), scroll(nearest), scroll(block) y scroll(nearest block) son cuatro formas de escribir exactamente lo mismo.

nearest busca el ancestro más cercano que sea un contenedor de scroll en el eje pedido. Esa última precisión es la que se olvida. Un div con overflow-x: auto es contenedor de scroll en el eje horizontal, y scroll(nearest block) lo ignorará por completo y seguirá subiendo por el árbol hasta encontrar algo que scrollee verticalmente, que casi siempre acabará siendo el documento.

root referencia el elemento raíz del documento. Como las referencias al elemento raíz se propagan al viewport del documento, en la práctica esto significa “la barra de scroll de la página”, independientemente de cuántos contenedores con desbordamiento haya en medio. Es lo que quieres para una barra de progreso de lectura o para cualquier efecto ligado a la página entera.

self referencia el propio elemento sobre el que se declara la animación, que por tanto tiene que ser él mismo un contenedor de scroll. Sirve para animar el contenedor en función de su propio desplazamiento: sombras que aparecen en los bordes cuando hay contenido oculto, indicadores de “hay más abajo”, encabezados internos que se compactan.

/* Una sombra en el borde superior que aparece solo cuando ya has scrolleado */
@keyframes sombra-superior {
  from { box-shadow: inset 0 0 0 0 oklch(0% 0 0 / 0); }
  to   { box-shadow: inset 0 8px 8px -8px oklch(0% 0 0 / 0.45); }
}

.panel {
  overflow-y: auto;
  max-block-size: 60vh;
  animation: sombra-superior linear both;
  animation-timeline: scroll(self);
  animation-range: 0 40px;
}

Ese patrón es representativo de lo que las timelines de scroll hacen bien: un efecto pequeño, puramente visual, atado a una condición geométrica que en JavaScript exigiría un listener por cada panel de la página.

Los cuatro ejes y el modo de escritura

<axis> admite block, inline, x e y. Los dos primeros son lógicos y los dos últimos son físicos, y la diferencia importa en cuanto tu sitio tenga una versión en árabe o en japonés vertical.

block es el eje en el que se apilan los bloques, que en escritura horizontal de arriba abajo es el vertical. inline es el eje del texto, que en ese mismo modo es el horizontal. En un documento con writing-mode: vertical-rl, los dos se intercambian: los bloques se apilan horizontalmente y el texto corre en vertical. x e y no se intercambian nunca: son el eje horizontal y el vertical de la pantalla, pase lo que pase.

La regla práctica es sencilla. Si el efecto depende de “según se avanza en la lectura”, usa block y déjalo respirar con el idioma. Si el efecto depende de una geometría física —una galería que se desliza horizontalmente porque así está construida, un carrusel de imágenes— usa x o y, porque lo que estás describiendo es una dirección de la pantalla, no una dirección del texto.

💡
El eje del scroller y el eje de la timeline no son la misma decisión

Confundirlos es el error número dos. overflow-x: auto decide qué barra existe. El <axis> de scroll() decide de cuál cuelgas la animación. Si construyes una galería horizontal con overflow-x: auto y escribes animation-timeline: scroll(nearest), has pedido el eje de bloque por defecto, la galería no scrollea en bloque, nearest sigue subiendo y acaba enganchándote la animación a la barra vertical de la página. La animación funcionará, se moverá, y se moverá con el scroll equivocado. Escribe scroll(nearest inline) o, si el layout es físicamente horizontal por diseño, scroll(nearest x).

Cuando no resuelve

Si nearest no encuentra ningún contenedor de scroll en el eje pedido y llega hasta el elemento raíz, se queda con el viewport del documento. Si el viewport tampoco scrollea en ese eje —una página corta, un overflow: hidden en html— la timeline queda inactiva y la animación no aplica ningún valor. Ya vimos en la lección anterior que eso no es un fallo ruidoso: no hay error en consola, no hay advertencia, simplemente el elemento se ve con su estilo base.

El procedimiento de diagnóstico ordenado, cuando “no se mueve”, es este:

Síntoma Causa probable Comprobación
No se mueve nada, se ve el estilo base Timeline inactiva ¿El scroller desborda de verdad en ese eje?
Se mueve con el scroll de la página, no con el del contenedor nearest subió de más ¿Coincide el eje pedido con el eje del contenedor?
Se completa entera en los primeros píxeles animation-duration con un tiempo Ponla en auto o quita el tiempo del atajo
Funcionaba y dejó de funcionar al refactorizar El atajo animation reseteó la timeline ¿Va animation-timeline después del atajo?
Nada en Firefox, todo bien en Chrome No implementado Es lo esperado, no es tu CSS

Hay un caso más, sutil, que merece nombre propio: self sobre un elemento que no es contenedor de scroll. No hay error; la timeline queda inactiva. Ocurre cuando alguien quita el overflow: auto porque “ya no hacía falta” y no se da cuenta de que había una animación colgando de él.

Cada scroll() es una timeline distinta, y eso se paga

La especificación dice algo que parece burocrático y tiene consecuencias reales: cada uso de scroll() corresponde a su propia instancia de ScrollTimeline, aunque dos elementos escriban exactamente la misma función apuntando exactamente al mismo scroller. No hay deduplicación. Si tienes una lista de doscientos elementos y cada uno declara animation-timeline: scroll(root), has creado doscientos objetos de timeline, doscientas suscripciones al mismo scroller y doscientas actualizaciones por frame. Funciona, y en máquinas de desarrollo no se nota, pero es exactamente el tipo de coste que aparece en un móvil de gama media como jank al scrollear. La versión correcta para ese caso es declarar una timeline nombrada con scroll-timeline-name en el scroller y que los doscientos elementos la referencien por nombre: una sola instancia, una sola suscripción. La regla de oro que se deduce: scroll() anónimo para lo único, nombres para lo repetido. Y hay un segundo motivo para preferir nombres que no es de rendimiento sino de mantenimiento: scroll(nearest) es una referencia posicional, y basta con que alguien envuelva tu elemento en un contenedor con overflow: hidden para que resuelva a otro sitio sin que nada avise. Un nombre no se rompe al reordenar el marcado.

El caso de la galería horizontal

Merece la pena verlo entero porque combina las dos decisiones y porque es el ejemplo donde el valor por defecto está siempre mal. Una tira de tarjetas que se desplaza horizontalmente, con una barra de progreso propia debajo:

.galeria {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
}

.galeria > * {
  flex: 0 0 min(80%, 22rem);
  scroll-snap-align: center;
}

@keyframes avanzar {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.galeria-progreso {
  block-size: 3px;
  background: currentColor;
  transform-origin: left center;
  animation: avanzar linear both;
  /* nearest resuelve a .galeria solo si pedimos el eje correcto */
  animation-timeline: scroll(nearest inline);
}

Para que nearest resuelva a .galeria, el elemento .galeria-progreso tiene que ser descendiente suyo, y ahí aparece el conflicto real: la barra de progreso no debería estar dentro de la galería, porque se scrollearía con ella. La solución no es forzar el árbol para que nearest funcione, sino dejar de usar nearest. Ese es justamente el problema que resuelven las timelines nombradas junto con timeline-scope, y es el contenido de la lección siguiente: timelines nombradas y cómo cruzar el árbol.