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

Timelines nombradas y timeline-scope

scroll-timeline-name y scroll-timeline-axis, las reglas de resolución de un nombre en el árbol, y timeline-scope para llegar donde la herencia no llega.

⏱ 19 min

La notación anónima scroll() resuelve por posición en el árbol, y toda referencia posicional se rompe en cuanto alguien mueve el marcado. Las timelines nombradas invierten el planteamiento: el scroller declara una vez que publica su progreso bajo un identificador, y cualquier elemento que pueda ver ese identificador se engancha. El precio es entender las reglas de visibilidad, que no son las de las custom properties aunque la sintaxis lo sugiera.

🎯 Al terminar esta lección sabrás
  • Declarar una timeline nombrada con scroll-timeline-name y su eje.
  • Predecir qué elementos pueden referenciar un nombre y cuáles no.
  • Usar timeline-scope en el ancestro común para llegar a hermanos y descendientes.
  • Resolver conflictos de nombre y saber qué gana cuando hay dos.

Declarar y referenciar

El scroller declara. El elemento animado referencia. Son dos propiedades distintas en dos reglas distintas:

.galeria {
  overflow-x: auto;
  scroll-timeline-name: --tira;
  scroll-timeline-axis: inline;
}

.galeria-progreso {
  animation: avanzar linear both;
  animation-timeline: --tira;
}

El nombre tiene que ser un <dashed-ident>: empieza obligatoriamente por dos guiones, igual que una custom property. No es una custom property y no participa en la cascada de la misma manera, pero comparte el espacio de nombres visual a propósito, para que se lea como algo definido por el autor y no como una palabra clave del lenguaje.

scroll-timeline-axis toma los mismos cuatro valores que el segundo argumento de scroll(): block, inline, x e y, con block por defecto. Y existe el atajo scroll-timeline, que combina nombre y eje:

.galeria {
  scroll-timeline: --tira inline;
}

Ambas propiedades aceptan listas separadas por comas, porque un mismo scroller puede publicar su eje horizontal y su eje vertical bajo dos nombres distintos:

.lienzo {
  overflow: auto;
  scroll-timeline: --horizontal x, --vertical y;
}

Un detalle de la especificación que ahorra depuraciones: un elemento solo puede definir una timeline por nombre. Si escribes scroll-timeline: --t inline, --t block, gana la última declarada dentro de la propiedad, es decir, la de bloque. Y si el mismo elemento define --t como scroll timeline y también como view timeline, gana la scroll timeline. Son reglas de desempate, no errores: no verás ninguna advertencia.

Quién puede ver un nombre

Aquí está la parte que hay que entender bien, porque el modelo mental por defecto —“funciona como una variable CSS”— es el equivocado.

La resolución de un nombre parte del elemento que lo referencia y sube por el árbol. Si el propio elemento define una timeline con ese nombre, esa es. Si no, se pregunta al padre, y al padre del padre, hasta arriba. Y en cada escalón se aplica la misma pregunta. El efecto neto, en la práctica que todos los motores implementan de forma predecible, es que un nombre declarado en un elemento es visible para sus descendientes, y no automáticamente para sus hermanos ni para sus ancestros.

Eso cubre bien el caso más habitual —el scroller envuelve al elemento animado— y deja fuera dos casos igual de habituales:

  • El elemento animado es hermano del scroller. La barra de progreso que va encima de la galería, no dentro.
  • El elemento animado es ancestro del scroller. Un contenedor que cambia de color según el scroll de un panel interior.

Para esos dos existe timeline-scope. Se declara en el ancestro común de ambos y eleva el nombre a ese nivel: a partir de ahí, todo el subárbol de ese ancestro puede ver la timeline, la declare quien la declare.

/* El ancestro comun eleva el nombre */
.seccion {
  timeline-scope: --tira;
}

/* Lo declara el scroller, que esta dentro de .seccion */
.seccion .galeria {
  overflow-x: auto;
  scroll-timeline: --tira inline;
}

/* Lo consume un hermano del scroller, tambien dentro de .seccion */
.seccion .galeria-progreso {
  animation: avanzar linear both;
  animation-timeline: --tira;
}

Con esas tres reglas, la barra de progreso puede vivir donde tenga sentido en el marcado en lugar de donde el algoritmo de resolución la obligue a estar. Ese es todo el propósito de la propiedad.

⚠️
timeline-scope: all ya no es una salida

Existió un valor all que elevaba todos los nombres de golpe. Chromium lo implementó en la 116 y lo retiró en la 138. Si te lo encuentras en un artículo de 2023 o en un fragmento copiado de un foro, está muerto: hoy hay que enumerar los nombres. Es una molestia menor y en realidad es mejor diseño, porque all convertía cualquier nombre en global sin dejar rastro de dónde se estaba consumiendo.

Nombres y componentes

Las timelines nombradas tienen una propiedad que las hace encajar bien en una arquitectura de componentes y que no es evidente hasta que la usas: el nombre es un contrato, y el contrato se puede cumplir desde sitios distintos sin que el consumidor se entere.

Un componente de barra de progreso que declare animation-timeline: --contenedor-scroll funciona igual si el nombre lo publica el documento, un panel lateral o un modal. La pieza que decide de dónde sale el progreso es la que compone, no la que anima. Con scroll(nearest) eso es imposible: el componente queda atado a la forma del árbol que le rodea.

El contrapunto honesto es el conflicto de nombres. Como los nombres suben por el árbol y timeline-scope los eleva, dos instancias del mismo componente dentro del mismo ámbito pelean por el mismo identificador. La regla de desempate es la que cabe esperar —gana la última declarada en el orden del árbol dentro de ese ámbito— y el resultado es que las dos instancias se enganchan al mismo scroller. La solución es la misma que en cualquier sistema con nombres globales: acotar el ámbito. Un timeline-scope en la raíz de cada instancia del componente hace que cada una resuelva dentro de su propia caja.

Esto no es una variable, y la diferencia se paga cara una vez

La sintaxis --nombre invita a pensar en custom properties, y el parecido termina ahí. Una custom property se hereda: la declaras en un ancestro y todos los descendientes leen su valor. Un nombre de timeline no se hereda; se busca. Y la búsqueda va en la dirección contraria a la intuición: para que un elemento vea una timeline, la timeline tiene que estar declarada en él mismo, o el nombre tiene que haber sido elevado con timeline-scope por alguno de sus ancestros. Declarar scroll-timeline-name: --t en el body no hace que --t esté disponible en todas partes de la forma en que lo haría una variable, porque lo que estás publicando es la timeline del body, no un valor que baje por herencia. Hay un segundo efecto que se descubre tarde y duele: timeline-scope crea un hueco reservado para ese nombre en ese ancestro. Si nadie dentro del subárbol declara una timeline con ese nombre, el hueco existe pero está vacío, y las animaciones que lo referencian se enganchan a una timeline inactiva: no aplican ningún valor, en silencio. Es decir, un timeline-scope con una errata en el nombre no produce un error de sintaxis ni un aviso; produce elementos que se quedan con su estilo base y una hora buscando el fallo en el sitio equivocado. Cuando algo no se mueve y hay timeline-scope de por medio, lo primero que hay que hacer es comparar los dos identificadores carácter a carácter.

El patrón completo

Juntando todo, la forma en que conviene escribir una animación de scroll nombrada en un proyecto real, con la detección que se desarrolla en el nivel 17 ya puesta desde el principio:

.lector {
  timeline-scope: --articulo;
}

.lector__cuerpo {
  overflow-y: auto;
  max-block-size: 100dvh;
  scroll-timeline: --articulo block;
}

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

.lector__barra {
  block-size: 4px;
  background: var(--acento, oklch(70% 0.15 250));
  /* Estado por defecto sin timeline: barra completa, inofensiva */
  transform: scaleX(1);
  transform-origin: left center;
}

@supports (animation-timeline: scroll()) {
  .lector__barra {
    animation: avanzar linear both;
    animation-timeline: --articulo;
  }
}

Fíjate en el orden dentro del bloque @supports: animation-timeline va después del atajo animation. No es estilo, es obligatorio, y la lección siguiente explica por qué esa línea se resetea sola si la pones antes.