wandres.dev
GSAP VIII · SplitText

El reflow responsive: por qué hay que volver a dividir

Por qué las líneas dejan de ser líneas al cambiar el ancho o al cargar la fuente, qué hace autoSplit, y por qué la animación tiene que vivir dentro de onSplit.

⏱ 19 min

La división por caracteres y por palabras sobrevive a cualquier cambio de ancho, porque las palabras siguen fluyendo y rompiendo donde toca. La división por líneas no: una línea es un contenedor que encierra un conjunto concreto de palabras, decidido para un ancho concreto, y en cuanto ese ancho cambia el conjunto correcto es otro. Ese es el problema que hunde la mayoría de las implementaciones caseras de animación de texto, y el que hace que un titular perfecto en el portátil salga con una palabra suelta colgando en el móvil.

🎯 Al terminar esta lección sabrás
  • Explicar por qué las líneas divididas no reflowean y las palabras sí.
  • Enumerar los dos sucesos que exigen volver a dividir: la carga de fuentes y el cambio de ancho.
  • Usar autoSplit junto con onSplit de forma que las animaciones sobrevivan a la redivisión.
  • Aprovechar la sincronización automática de tiempo que ofrece devolver la animación desde onSplit.

Una línea es una decisión congelada

Cuando el navegador compone un párrafo, decide dónde romper cada línea en función del ancho disponible, del tamaño de la fuente y de las métricas de los glifos. Esa decisión se rehace cada vez que cambia cualquiera de esos factores, y por eso el texto normal se adapta.

Al dividir por líneas, SplitText toma esa decisión tal como está en ese instante y la materializa: crea un contenedor por línea y mete dentro las palabras que el navegador había puesto en ella. A partir de ahí, esas palabras están encerradas. Si la ventana se estrecha, la última palabra de la primera línea ya no cabe, pero no puede bajar a la segunda: bajará dentro de su propio contenedor, produciendo una línea de dos alturas con una palabra suelta debajo.

Lo mismo pasa, y con más frecuencia, con las fuentes web. Si divides antes de que la fuente termine de cargar, la división se hace con las métricas de la fuente de reserva; cuando llega la definitiva, los anchos de los glifos cambian, y las líneas que habías materializado dejan de corresponder con las que el navegador habría compuesto.

Esos son los dos sucesos que exigen volver a dividir, y no hay un tercero relevante:

  • La fuente termina de cargar.
  • Cambia el ancho del elemento dividido, siempre que estés dividiendo por líneas.

Volver a dividir significa literalmente eso: restaurar el HTML original y ejecutar la lógica de división otra vez, para que los contenedores de línea encierren el conjunto correcto de palabras.

ℹ️
Solo el ancho, y solo con líneas

Un cambio de altura no afecta a la composición de líneas, así que no dispara redivisión. Y si solo divides por palabras o por caracteres, no hay nada que redividir porque no hay contenedores de línea: los elementos fluyen solos. Por eso autoSplit está condicionado a esas dos circunstancias a la vez.

autoSplit y el problema que crea

autoSplit: true hace exactamente lo anterior por ti: revierte y vuelve a dividir cuando las fuentes terminan de cargar o cuando cambia el ancho del elemento con líneas divididas. Además, SplitText emite un aviso por consola si divides antes de que las fuentes estén listas y no lo has activado.

Pero activarlo crea un problema nuevo, y es el que hay que entender bien. Al redividir se destruyen los elementos anteriores y se crean otros nuevos. Tu animación estaba apuntando a los viejos, que ya no existen en el DOM. El resultado es un texto que desaparece, o que se queda a medio animar, o que aparece de golpe sin animación.

La solución es no crear la animación fuera, sino dentro de onSplit, que se ejecuta cada vez que la división ocurre, incluida cada redivisión.

import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";

gsap.registerPlugin(SplitText);

SplitText.create(".titular", {
  type: "lines, words",
  autoSplit: true,
  onSplit(self) {
    return gsap.from(self.lines, {
      yPercent: 100,
      autoAlpha: 0,
      duration: 0.8,
      stagger: 0.1,
      ease: "power3.out",
    });
  },
});

Hay dos cosas en ese código que no son opcionales.

La primera es que la animación usa self.lines, el array de la instancia que se pasa al callback, y no una variable capturada fuera. Cada llamada a onSplit trae los elementos recién creados.

La segunda es la palabra return. Es la que activa el comportamiento más útil de todo el sistema.

Por qué devolver la animación

Cuando devuelves un tween o una timeline desde onSplit, SplitText hace tres cosas por su cuenta:

Guarda el totalTime() de la animación anterior, es decir, en qué punto exacto de su reproducción estaba.

Revierte y mata la animación anterior, de modo que no quedan tweens huérfanos apuntando a nodos eliminados.

Coloca la animación nueva en ese mismo totalTime(), con lo que la redivisión resulta prácticamente invisible: si el usuario redimensiona la ventana a mitad del revelado, el revelado continúa desde donde iba en lugar de reiniciarse o de romperse.

Sin el return, las dos primeras no ocurren: cada redivisión deja atrás una animación viva sobre elementos muertos, y eso es una fuga de memoria que crece con cada arrastre del borde de la ventana.

// MAL: la animacion se crea fuera y apunta a elementos que dejaran de existir
const split = SplitText.create(".titular", { type: "lines", autoSplit: true });
gsap.from(split.lines, { yPercent: 100, stagger: 0.1 });

// MAL: dentro de onSplit pero sin return. No hay limpieza ni sincronizacion
SplitText.create(".titular", {
  type: "lines",
  autoSplit: true,
  onSplit(self) {
    gsap.from(self.lines, { yPercent: 100, stagger: 0.1 });
  },
});

// BIEN
SplitText.create(".titular", {
  type: "lines",
  autoSplit: true,
  onSplit: (self) => gsap.from(self.lines, { yPercent: 100, stagger: 0.1 }),
});

La alternativa sin autoSplit

Si prefieres control manual, o si tu caso no encaja, el patrón mínimo es esperar a las fuentes antes de dividir y volver a dividir a mano cuando haga falta.

document.fonts.ready.then(() => {
  const split = SplitText.create(".titular", { type: "lines" });

  gsap.from(split.lines, { yPercent: 100, autoAlpha: 0, stagger: 0.1 });

  let anchoAnterior = window.innerWidth;
  window.addEventListener("resize", () => {
    if (window.innerWidth === anchoAnterior) return; // ignorar cambios de alto
    anchoAnterior = window.innerWidth;
    split.split({ type: "lines" });                  // rehace la division
  });
});

split.split() vuelve a dividir con la configuración que le pases, y split.revert() restaura el HTML original. El filtro de ancho es importante: en móvil, la barra de direcciones que aparece y desaparece dispara eventos de resize continuamente sin que el ancho cambie, y redividir en cada uno es un derroche notable.

Si además tienes una animación de scroll asociada al texto, recuerda que la redivisión cambia la altura del bloque, así que hay que refrescar. Es el mismo mecanismo que viste en la lección sobre el refresco de ScrollTrigger.

autoSplit no es una comodidad: es el reconocimiento de que dividir por líneas rompe la idempotencia de tu layout

Vale la pena mirar este problema desde una distancia mayor, porque la lección va bastante más allá de la tipografía. Un layout web bien construido tiene una propiedad que casi nunca nombramos y de la que dependemos constantemente: es una función pura del ancho disponible. Le das un ancho al navegador y obtiene una disposición; le das el mismo ancho más tarde y obtiene exactamente la misma disposición, sin importar por qué caminos haya pasado. Esa pureza es lo que hace que redimensionar una ventana funcione, que girar un móvil funcione y que una media query no deje residuos. Dividir por líneas rompe esa propiedad de raíz, porque introduce en el DOM una estructura que codifica el resultado de una composición hecha para un ancho concreto: a partir de ese momento, el estado visible depende del historial y no solo del ancho actual. Dos usuarios con la misma ventana pueden ver cosas distintas según si uno la redimensionó y el otro no. autoSplit restaura la pureza por el único camino posible, que es detectar el cambio de entrada y recomputar la salida desde cero, exactamente lo que hace cualquier sistema reactivo cuando una dependencia cambia. Y de ahí sale la exigencia de poner la animación dentro de onSplit: si la salida se recomputa, todo lo que dependa de la salida tiene que recomputarse también, y una animación que apunta a los nodos de la computación anterior es la versión visual de una referencia colgante. El detalle de guardar y restaurar el totalTime() es la guinda, y es una idea que merece robarse para otros contextos: cuando tengas que reconstruir algo que estaba en marcha, reconstruye también su posición temporal, porque para el usuario la continuidad del tiempo es más importante que la identidad de los objetos.

⚔️ Romper y arreglar el reflow
  1. Divide un párrafo por líneas sin autoSplit, estrecha la ventana y localiza la palabra que cuelga.
  2. Divide antes de que carguen las fuentes con la red simulada a velocidad lenta, y observa el desajuste al llegar la fuente definitiva.
  3. Activa autoSplit con la animación fuera de onSplit y redimensiona a mitad del revelado. Describe qué ocurre.
  4. Mueve la animación dentro de onSplit sin return, y luego con return. Compara qué pasa al redimensionar a mitad.
  5. Implementa la versión manual con document.fonts.ready y el filtro de ancho, y comprueba en un móvil que la barra de direcciones ya no provoca redivisiones.