wandres.dev
GSAP IX · Flip

Mover un elemento de un contenedor a otro

El caso del elemento que cambia de padre, la correlación por data-flip-id, el cruce de dos elementos distintos con fade, y Flip.fit para encajar sin mover.

⏱ 18 min

Arrastrar una tarjeta de una columna a otra en un tablero, promocionar un elemento de la rejilla a una posición destacada, mover una miniatura de la galería al reproductor principal: son transiciones donde el elemento cambia de padre, y en las que el planteamiento ingenuo produce un salto brusco porque el nodo desaparece de un sitio y aparece en otro sin nada en medio. Es también el caso en el que Flip revela su función más específica: la capacidad de tratar dos elementos distintos como si fueran el mismo, correlacionándolos por un identificador que pones tú.

🎯 Al terminar esta lección sabrás
  • Mover un nodo entre contenedores conservando la continuidad visual.
  • Correlacionar dos elementos distintos con data-flip-id y cruzarlos con fade.
  • Usar Flip.fit() para encajar un elemento sobre otro sin cambiar el DOM.
  • Reconocer los tres fallos habituales de este patrón y su remedio.

El mismo nodo, otro padre

El caso base no necesita nada especial: capturas, mueves el nodo, animas.

<div class="tablero">
  <section class="columna" id="pendiente"><h3>Pendiente</h3></section>
  <section class="columna" id="curso"><h3>En curso</h3></section>
  <section class="columna" id="hecho"><h3>Hecho</h3></section>
</div>
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";

gsap.registerPlugin(Flip);

function moverTarea(tarea, columnaDestino) {
  const estado = Flip.getState(tarea);

  columnaDestino.appendChild(tarea);

  return Flip.from(estado, {
    duration: 0.5,
    ease: "power2.inOut",
    absolute: true,
    zIndex: 200,
  });
}

document.querySelectorAll(".tarea").forEach((tarea) => {
  tarea.addEventListener("click", () => {
    const actual = tarea.closest(".columna");
    const siguiente = actual.nextElementSibling ?? document.querySelector("#pendiente");
    moverTarea(tarea, siguiente);
  });
});

Dos opciones son necesarias aquí y no lo parecen. absolute: true saca la tarea del flujo mientras viaja, para que no aparezca dentro de la columna de destino empujando a las demás antes de haber llegado. zIndex: 200 la mantiene por encima de ambas columnas durante el trayecto; sin él, en cuanto la tarea entra en el DOM de la columna destino, el orden de apilamiento cambia y puede pasar por debajo del borde de la otra columna a mitad del viaje.

Fíjate en que la función devuelve la timeline, lo cual permite encadenar o esperar.

⚠️
Ancestros con transform, otra vez

Si alguna columna tiene una transformación aplicada, absolute: true posiciona respecto a ese ancestro y no respecto al viewport, con lo que el trayecto sale mal. Es la misma trampa del position: fixed que aparece con la fijación de ScrollTrigger, y por la misma razón: un transform crea un bloque contenedor nuevo. Si no puedes evitarlo, mueve el elemento a un contenedor sin transformaciones durante el viaje.

Dos elementos distintos que se cruzan

El caso más interesante es cuando el origen y el destino son elementos diferentes. Una miniatura en una galería y una imagen grande en un panel; una fila de tabla y un panel de detalle; un avatar pequeño en una lista y otro grande en una ficha.

Flip resuelve esto con data-flip-id. Si dos elementos comparten el mismo valor de ese atributo y uno de ellos deja de estar visible mientras el otro aparece, Flip los trata como el mismo objeto y anima del rectángulo de uno al del otro.

<figure class="miniatura" data-flip-id="foto-7">
  <img src="/fotos/7-peq.jpg" alt="Puente al amanecer">
</figure>

<figure class="grande" data-flip-id="foto-7" hidden>
  <img src="/fotos/7-grande.jpg" alt="Puente al amanecer">
</figure>
const mini = document.querySelector('.miniatura[data-flip-id="foto-7"]');
const grande = document.querySelector('.grande[data-flip-id="foto-7"]');

mini.addEventListener("click", () => {
  const estado = Flip.getState([mini, grande]);

  mini.hidden = true;
  grande.hidden = false;

  Flip.from(estado, {
    duration: 0.6,
    ease: "power3.inOut",
    absolute: true,
    fade: true,
    scale: true,
  });
});

fade: true es lo que convierte el intercambio brusco en un cruce: en lugar de que la miniatura desaparezca de golpe cuando aparece la grande, ambas se solapan y se funden durante el trayecto. Sin él, el cambio es instantáneo y con imágenes de distinta resolución se nota.

absolute: true es prácticamente obligatorio aquí. La documentación lo explica: para que el elemento que se va sea visible durante el cruce, Flip tiene que forzar su display, y si no está fuera del flujo alteraría la posición de todo lo demás.

scale: true tiene sentido cuando el contenido es una imagen. Con texto, quítalo.

Flip.fit: encajar sin mover el DOM

Hay un tercer método menos conocido que resuelve un problema distinto: colocar un elemento exactamente encima de otro, sin cambiar el árbol.

// Colocar el indicador sobre la pestana activa
Flip.fit(".indicador", ".pestana.activa", { duration: 0.35, ease: "power2.out" });

Flip.fit() calcula la transformación necesaria para que el primer elemento ocupe exactamente la misma zona que el segundo, y la anima. Por defecto ajusta x, y, rotation, skewX, width y height; con scale: true usa escalas en vez de dimensiones.

Es la herramienta correcta para indicadores deslizantes, para resaltados que siguen al ratón, para marcos que enmarcan elementos cambiantes. En todos esos casos no hay ningún cambio de layout: hay un elemento que tiene que colocarse encima de otro, y hacerlo a mano requiere medir ambos y componer la transformación, que es exactamente lo que fit automatiza.

El segundo argumento admite además un objeto de estado en lugar de un elemento, lo cual permite encajar un elemento sobre una posición pasada de sí mismo.

Los tres fallos habituales

El elemento parpadea al empezar. Casi siempre falta absolute: true, y lo que ves es el frame en el que el elemento ya está en el flujo del destino pero la transformación aún no se ha aplicado. También puede ser un Flip.from() llamado antes de que el navegador haya aplicado el cambio de DOM, cosa que ocurre en frameworks con renderizado diferido.

El elemento pasa por debajo de algo a mitad del viaje. Falta zIndex. Al cambiar de padre, el contexto de apilamiento cambia, y el orden de pintado con él.

La animación no ocurre y no hay ningún error. El estado apunta a nodos que ya no existen. Ocurre siempre que la fase Last recree elementos en lugar de moverlos. Se arregla con data-flip-id y targets.

// Diagnostico: comprobar si Flip esta animando algo de verdad
const tl = Flip.from(estado, { duration: 0.5 });
console.log("duracion de la timeline:", tl.duration());
// Si sale 0, Flip no encontro ninguna diferencia que animar
data-flip-id es un mecanismo de identidad explícita, y es la respuesta a un problema que los frameworks resuelven con la misma idea y otro nombre

Hay una simetría que merece señalarse porque une dos mundos que se suelen enseñar por separado. Cuando escribes una lista en React o en Vue y te obligan a poner una key, lo que estás proporcionando es una identidad estable e independiente de la posición: le estás diciendo al reconciliador que el elemento que estaba en el índice 3 y el que ahora está en el índice 7 son la misma entidad conceptual, aunque su posición en el array haya cambiado. Sin esa información, el algoritmo solo puede casar por posición, y casar por posición produce exactamente los bugs que todos hemos visto: estados de componente que se quedan pegados al índice equivocado, animaciones que aparecen donde no toca, campos de formulario que cambian de dueño. data-flip-id es literalmente el mismo mecanismo aplicado al problema de la continuidad visual: le dice a Flip que la miniatura de la galería y la imagen grande del panel, que son dos nodos distintos con dos etiquetas distintas en dos sitios distintos del árbol, representan el mismo objeto desde el punto de vista del usuario. Y esa afirmación no la puede deducir ninguna biblioteca de la estructura del DOM: es información semántica que solo existe en tu cabeza y en tu modelo de datos. Fíjate en que el mismo patrón aparece por tercera vez en view-transition-name de las View Transitions nativas, con nombre distinto y significado idéntico. Tres tecnologías independientes llegaron a la misma conclusión: cuando dos representaciones distintas se refieren a la misma cosa, alguien tiene que decirlo explícitamente, y ese alguien eres tú. Cuando diseñes tu propio sistema de transiciones, empieza por ahí: no por las animaciones, sino por decidir qué entidades de tu dominio tienen identidad persistente a través de los cambios de vista.

⚔️ Cambiar de padre sin que se note
  1. Monta el tablero de tres columnas y mueve una tarea. Quita zIndex y busca el frame en el que pasa por debajo.
  2. Quita absolute: true y describe cómo se comportan las tarjetas de la columna de destino.
  3. Monta el cruce de miniatura a imagen grande con data-flip-id. Prueba con fade: true y sin él.
  4. Sustituye la miniatura oculta con hidden por una que se elimine del DOM y comprueba qué hace falta cambiar.
  5. Implementa un indicador de pestaña deslizante con Flip.fit() y compáralo con hacerlo a mano midiendo con getBoundingClientRect.