Patrones resueltos: lista que se reordena, revelado y transición de página
FLIP con WAAPI interrumpible y completo, el revelado al hacer scroll con timeline nativa y retroceso accesible, y la transición de página con View Transitions en aplicación y en sitio de varios documentos.
Los tres patrones de esta lección tienen en común que el navegador no puede animarlos solo, porque en los tres el estado antes y el estado después son dos disposiciones del documento distintas y no hay ninguna propiedad que las conecte. Las tres soluciones consisten en lo mismo: capturar el antes, dejar que ocurra el después, y animar la diferencia. Lo que cambia es quién hace la captura.
- Implementar FLIP con WAAPI de forma correcta ante interrupciones.
- Montar un revelado al hacer scroll con timeline nativa y retroceso que no rompa la accesibilidad.
- Usar View Transitions en el mismo documento y entre documentos con su respectivo retroceso.
- Nombrar elementos compartidos para que dos pantallas se lean como dos estados de un objeto.
La lista que se reordena
FLIP es el nombre de la técnica: First, Last, Invert, Play. Mides dónde está cada elemento, dejas que el DOM cambie, mides dónde ha acabado, aplicas la transformación inversa para que visualmente siga donde estaba, y la animas hasta la identidad. El navegador nunca sabe que hubo animación: lo que anima es un transform, que va al compositor.
Esta implementación es completa y aguanta interrupciones, que es donde fallan casi todas.
const CURVA = "cubic-bezier(0.4, 0, 0.2, 1)";
const ESCALA = [100, 150, 250, 320, 400];
const ajustar = (ms) => ESCALA.reduce((a, b) => (Math.abs(b - ms) < Math.abs(a - ms) ? b : a));
export function flip(elementos, mutar, { curva = CURVA, base = 250 } = {}) {
const lista = [...elementos];
// FIRST: la caja visual actual, que incluye el transform de una animacion
// en curso. Por eso se mide ANTES de cancelar nada.
const antes = new Map(lista.map((el) => [el, el.getBoundingClientRect()]));
// Se cancela lo que hubiera para que la medida del despues sea la real.
for (const el of lista) {
for (const a of el.getAnimations()) {
if (a.id === "flip") a.cancel();
}
}
// LAST: el DOM cambia y se vuelve a medir. Todas las lecturas juntas.
mutar();
const despues = new Map(lista.map((el) => [el, el.getBoundingClientRect()]));
// INVERT y PLAY.
const animaciones = [];
for (const el of lista) {
const a = antes.get(el);
const b = despues.get(el);
if (!a || !b || b.width === 0) continue;
const dx = a.left - b.left;
const dy = a.top - b.top;
const sx = a.width / b.width;
const sy = a.height / b.height;
const quieto =
Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 &&
Math.abs(sx - 1) < 0.01 && Math.abs(sy - 1) < 0.01;
if (quieto) continue;
const duracion = ajustar(base * Math.sqrt(Math.max(Math.hypot(dx, dy), 1) / 200));
animaciones.push(
el.animate(
[
{ transformOrigin: "top left", transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` },
{ transformOrigin: "top left", transform: "none" },
],
{ duration: duracion, easing: curva, id: "flip" }
)
);
}
return Promise.all(animaciones.map((a) => a.finished.catch(() => {})));
}
Cuatro decisiones que hacen que esto sea correcto y no una demo:
Se mide antes de cancelar. getBoundingClientRect() devuelve la caja visual, con las transformaciones aplicadas. Si cancelas primero, el elemento salta a su posición sin transformar y mides el sitio equivocado; el resultado es el salto característico de un FLIP mal implementado cuando lo interrumpes.
Todas las lecturas van juntas y todas las escrituras después. Leer y escribir intercalado en un bucle produce la escalera de layout síncrono forzado que veías en el flame chart.
Se descartan los elementos que no se movieron. En una lista de cien donde cambian tres, animar cien es crear noventa y siete capas de compositor para nada.
La duración se deriva de la distancia, con la relación sublineal y ajustada a la escala de tokens, así que un elemento que se mueve un puesto y otro que cruza la lista entera se sienten coherentes entre sí.
Y el uso, con la versión reducida:
const sinMovimiento = matchMedia("(prefers-reduced-motion: reduce)");
function ordenar(criterio) {
const items = [...lista.children];
const mutar = () => {
items
.sort((a, b) => a.dataset[criterio].localeCompare(b.dataset[criterio]))
.forEach((el) => lista.append(el));
};
if (sinMovimiento.matches) {
// Sustitucion, no supresion: un fundido corto de todo el contenedor
// marca el antes y el despues sin ningun desplazamiento.
lista.animate([{ opacity: 0.4 }, { opacity: 1 }], { duration: 120 });
mutar();
return;
}
flip(items, mutar);
}
El revelado al hacer scroll
Dos capas: la nativa donde exista, y un retroceso que no rompa nada donde no.
@keyframes revelar {
from { opacity: 0; translate: 0 24px; }
to { opacity: 1; translate: 0 0; }
}
/* Capa nativa. Solo se activa si hay soporte y si no hay preferencia
de movimiento reducido. Sin JavaScript de por medio. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
[data-revelar] {
animation: revelar linear both;
animation-timeline: view();
animation-range: entry 0% entry 90%;
}
}
}
Lo importante de esta forma de escribirlo: el contenido está visible por defecto. La ocultación solo existe dentro de la animación, y la animación solo existe donde hay soporte y no hay preferencia. Sin JavaScript, sin soporte, o con movimiento reducido, el contenido se pinta normal. Ninguna de las tres situaciones produce contenido invisible pero enfocable, que es el fallo de accesibilidad clásico de este patrón.
El retroceso para los motores sin timelines de scroll —Firefox no las ha implementado— se activa solo cuando hace falta:
const haySoporte = CSS.supports("animation-timeline: view()");
const sinMovimiento = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!haySoporte && !sinMovimiento && "IntersectionObserver" in window) {
const elementos = document.querySelectorAll("[data-revelar]");
// La ocultacion la pone JavaScript, no el CSS: si esta linea no se
// ejecuta, el contenido simplemente esta visible.
for (const el of elementos) el.style.opacity = "0";
const obs = new IntersectionObserver(
(entradas) => {
for (const e of entradas) {
if (!e.isIntersecting) continue;
obs.unobserve(e.target);
const anim = e.target.animate(
[
{ opacity: 0, transform: "translateY(24px)" },
{ opacity: 1, transform: "none" },
],
{ duration: 400, easing: "cubic-bezier(0, 0, 0.2, 1)", fill: "both" }
);
// Se limpia el rastro para no dejar estilo en linea ni animacion viva.
anim.finished.then(() => {
anim.cancel();
e.target.style.opacity = "";
});
}
},
{ rootMargin: "0px 0px -10% 0px" }
);
for (const el of elementos) obs.observe(el);
}
Antes de implementarlo, la pregunta honesta: ¿qué aporta que el contenido aparezca al llegar en lugar de estar ahí? En una página de producto, un poco de ritmo. En documentación, en un panel de datos o en cualquier cosa que se lea, nada: solo retrasa la lectura y obliga a esperar a que el texto termine de aparecer para poder leerlo. Y en el peor caso, si el observador falla, el contenido no aparece nunca.
La transición de página
En una aplicación de un solo documento, startViewTransition captura el estado visual, ejecuta tu actualización del DOM y anima entre las dos capturas.
const sinMovimiento = matchMedia("(prefers-reduced-motion: reduce)");
export function navegar(actualizarDOM) {
if (!document.startViewTransition || sinMovimiento.matches) {
actualizarDOM();
return;
}
document.startViewTransition(() => actualizarDOM());
}
En un sitio de varios documentos, la transición se declara y el navegador se encarga de la navegación entera, sin ninguna aplicación de por medio. Tiene que estar en las dos páginas:
@view-transition { navigation: auto; }
La personalización se hace sobre los pseudoelementos que el navegador genera:
::view-transition-old(root) {
animation: salir var(--mov-corta) var(--mov-salida) both;
}
::view-transition-new(root) {
animation: entrar var(--mov-media) var(--mov-entrada) both;
}
@keyframes salir { to { opacity: 0; translate: 0 -8px; } }
@keyframes entrar { from { opacity: 0; translate: 0 8px; } }
/* La version reducida: cruce de opacidad, sin desplazamiento. */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*) {
animation-name: none;
animation-duration: 120ms;
}
}
Y el elemento compartido, que es lo que convierte dos pantallas en dos estados del mismo objeto. Basta con darle el mismo nombre en las dos, y el nombre tiene que ser único en el documento:
.tarjeta-producto[data-id="4821"] .portada { view-transition-name: portada-4821; }
.detalle-producto[data-id="4821"] .portada { view-transition-name: portada-4821; }
// Con nombres generados, asignalos justo antes y quitalos despues:
// dos elementos con el mismo nombre a la vez rompen la transicion.
function irADetalle(tarjeta, id) {
const portada = tarjeta.querySelector(".portada");
portada.style.viewTransitionName = "portada-activa";
const vt = document.startViewTransition(() => pintarDetalle(id));
vt.finished.finally(() => {
portada.style.viewTransitionName = "";
});
}
El estado del soporte, para que no te lleves sorpresas: las transiciones de vista del mismo documento son Baseline desde 2025; las de entre documentos están llegando durante 2026 y todavía no son universales. Como toda la API está detrás de una comprobación de existencia y el CSS se ignora donde no se entiende, el retroceso es no tener transición, que es perfectamente aceptable.
Vale la pena verlo explícito porque unifica tres cosas que se enseñan por separado. En FLIP, tú guardas la foto del antes: llamas a getBoundingClientRect sobre cada elemento y te quedas con sus cajas. En una transición de vista, el navegador guarda la foto del antes: captura el árbol de renderizado completo en imágenes antes de dejar que mutes el DOM. En el revelado al hacer scroll no hay foto porque no hay mutación, pero la estructura es la misma con el tiempo sustituido por la posición: hay un estado inicial declarado, un estado final, y un progreso que los conecta. Las tres son instancias de una única idea, que es la idea central de toda la animación de interfaz: una animación es la interpolación entre dos estados que el sistema conoce por separado y que por sí solo enlazaría con un salto. Todo el trabajo técnico —medir cajas, capturar árboles, declarar @starting-style, retrasar display con allow-discrete— existe para lo mismo: conseguir que ambos estados coexistan el tiempo suficiente para poder interpolar entre ellos. Y la evolución de la plataforma en los últimos años se lee entera bajo esa luz: cada API nueva es un paso más de captura del estado anterior que se ha movido de tu código al del navegador. Primero fue medir cajas a mano; luego llegó una propiedad que retrasa la desaparición; luego un mecanismo que declara el estado previo de algo que aún no existía; y ahora una API que fotografía el documento entero. La dirección es inequívoca, y lo interesante para tus decisiones de hoy es que el trabajo de captura que sigue siendo tuyo es exactamente el que la plataforma todavía no ha absorbido, y por tanto el que más probablemente vas a poder borrar dentro de dos o tres años. Escribirlo de forma que se pueda borrar —aislado, detrás de una comprobación de soporte, sin extenderse por toda la base de código— es lo que separa una implementación que envejece bien de una que se convierte en deuda.