Módulo 13: Transiciones, animaciones y 3D

Objetivos del módulo

  • Distinguir una transición de una animación y saber cuándo usar cada una
  • Entender qué rehace el navegador en cada fotograma y por qué translate y opacity son más baratas que width
  • Animar cambios de estado con transition-*, duration-*, delay-* y ease-*
  • Evitar transition-all por costumbre y entender por qué dos clases transition-* no se suman
  • Mover, girar, escalar y deformar elementos con translate-*, rotate-*, scale-*, skew-* y origin-*
  • Entender las propiedades individuales de v4 y qué rompen al migrar: transition-[transform], transform-none y las animaciones
  • Usar animate-spin, animate-ping, animate-pulse y animate-bounce en casos reales
  • Crear animaciones propias con @keyframes dentro de @theme, y su equivalente en tailwind.config.js
  • Animar la aparición de un elemento con starting: y su desaparición con transition-discrete
  • Respetar la preferencia de movimiento reducido con motion-safe: y motion-reduce:
  • Trabajar en 3D con rotate-x-*, rotate-y-*, translate-z-*, perspective-*, transform-3d y backface-hidden
  • Construir una tarjeta que gira y un cubo, y evitar el error de orden que aparece al montarlo
  • Diagnosticar por qué una animación no se ve, va a saltos o aplana un elemento 3D

1. Preparación del laboratorio

Sigues con el proyecto Vite del módulo 01. Los ejemplos reutilizan las dos clases de decoración del módulo 08, .caja y .marco, que solo dibujan cajas de colores y marcos discontinuos. Si creas un proyecto nuevo, añádelas a app.css:

@import "tailwindcss";

@layer components {
  .caja {
    border-radius: 0.375rem;
    background-color: #6366f1;
    padding: 0.5rem 0.75rem;
    color: #fff;
    font-size: 0.875rem;
    font-weight: 500;
    text-align: center;
  }
  .marco {
    border: 2px dashed #cbd5e1;
    border-radius: 0.5rem;
    background-color: #f8fafc;
    padding: 0.25rem;
  }
}

Una animación no se puede imprimir, así que muchas figuras de este módulo son tiras de fotogramas: el mismo elemento congelado en varios instantes, con el milisegundo debajo. Son capturas reales. Para sacarlas se ha detenido cada animación con la API getAnimations() del navegador y se ha fijado su tiempo a mano. Tú puedes hacer lo mismo con el panel Animations de las herramientas de desarrollo, como se cuenta en la sección 12.

Si necesitas repasar las transiciones y animaciones de CSS puro, tienes el Curso de HTML y CSS.

⚠️ Importante: Vite no detecta siempre los HTML creados con el servidor en marcha. Si una clase nueva no aparece, guarda app.css sin cambiar nada para que Tailwind vuelva a escanear el proyecto.


2. Cómo se mueve algo en una página

Transición o animación

CSS tiene dos mecanismos para mover cosas, y conviene no mezclarlos:

  Transición Animación
Qué la dispara Un cambio de estado: hover, una clase nueva, abrir un menú Basta con que el elemento tenga la animación
Cuántos estados tiene Dos: el de antes y el de después Los que definas en @keyframes
Se repite No Sí, si lo pides (infinite)
Se invierte sola al volver Sí No
Clases de Tailwind transition-*, duration-*, ease-*, delay-* animate-*
Uso típico Botones, tarjetas, menús Spinners, esqueletos de carga, avisos que entran

📘 Concepto: Una transición dice “cuando esta propiedad cambie, no saltes de golpe: recorre el camino en este tiempo”. No define el destino: ese lo pone otra clase, como hover:bg-orange-500. Una animación es un guion completo, con su inicio, su final y sus pasos intermedios, que se ejecuta por sí solo.

La regla práctica es sencilla: si responde a algo que hace el usuario, normalmente es una transición. Si ocurre porque sí (algo está cargando, un aviso aparece), normalmente es una animación.

Qué hace el navegador en cada fotograma

Para que el movimiento parezca fluido, el navegador tiene que producir unos 60 fotogramas por segundo, es decir, uno cada 16 ms aproximadamente. Si un fotograma tarda más, se pierde y el ojo nota un salto. Lo que tenga que rehacer en esos 16 ms depende de la propiedad que animes:

Cuatro fases del navegador: estilo, layout, pintado y composición. Animar width rehace las tres últimas; color, las dos últimas; transform y opacity, solo la composición

Figura 1. Cuanto antes entre una propiedad en la cadena, más trabajo cuesta cada fotograma.

  • Layout: calcular dónde va y cuánto mide cada elemento. Si cambias el ancho de una tarjeta, puede que todas las de la fila se recoloquen.
  • Pintado: rellenar los píxeles. Un box-shadow grande cuesta bastante, porque hay que repintar la sombra.
  • Composición: el navegador tiene varias capas ya pintadas y solo las coloca una sobre otra. Es muy barato y lo suele hacer la tarjeta gráfica.

De aquí sale el consejo más importante del módulo: anima transform y opacity siempre que puedas. Una tarjeta que sube 4 píxeles con translate no obliga a recolocar nada: el resto de la página ni se entera de que se ha movido, porque su hueco en el diseño sigue siendo el mismo. Si la subes cambiando margin-top, el navegador recalcula el diseño en cada fotograma.

📘 Concepto: Las propiedades translate, rotate y scale que usa Tailwind 4 se comportan como transform a estos efectos: tampoco tocan el layout.


3. Transiciones: transition-*

Sin transición, un cambio de estado es instantáneo. Fíjate en estas dos tiras, con el mismo botón al activarse hover:

<!-- Salto brusco -->
<button class="bg-indigo-600 hover:bg-orange-500">Comprar</button>

<!-- Paso gradual -->
<button class="bg-indigo-600 transition-colors duration-300 hover:bg-orange-500">Comprar</button>

Sin transición el botón ya está naranja en el primer fotograma

Figura 2. Sin transición, el cambio se aplica entero en el primer fotograma.

Con transition-colors y duration-300 el botón pasa del azul al naranja en 300 ms

Figura 3. Con transition-colors duration-300, el navegador calcula los colores intermedios.

Observa dónde va transition-colors: en el estado normal, no en hover:. Así la transición funciona en las dos direcciones, al entrar y al salir. Si escribes hover:transition-colors, la entrada se anima, pero la salida no, porque al quitar el ratón la clase deja de aplicarse.

La familia de clases

Clase Propiedades que anima
transition Las habituales: colores, opacity, sombras, transformaciones, filtros
transition-colors Colores de fondo, texto, borde, contorno, relleno y trazo, y los degradados
transition-opacity opacity
transition-shadow box-shadow
transition-transform transform, translate, scale y rotate
transition-all Absolutamente todas
transition-none Ninguna
transition-[height] La que indiques, con un valor arbitrario

Este es el CSS real de transition-colors en Tailwind 4.3.3:

.transition-colors {
  transition-property: color, background-color, border-color, outline-color,
    text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via,
    --tw-gradient-to;
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
  transition-duration: var(--tw-duration, var(--default-transition-duration));
}

Toda clase transition-* hace tres cosas: elige las propiedades, y pone una curva y una duración por defecto (cubic-bezier(0.4, 0, 0.2, 1) y 150ms). Por eso transition-colors funciona sin más, y duration-* solo hace falta si quieres otro tiempo.

Listas ampliadas 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) transition y transition-colors no incluyen outline-color, los degradados ni translate, scale y rotate, y transition tampoco incluye display, overlay, content-visibility ni pointer-events. Si usas la LTS: para animar el color del contorno usa transition-[outline-color] o escribe la lista completa con un valor arbitrario; las transformaciones funcionan igual porque en v3 todas viven dentro de transform.

Dos clases transition-* no se suman

Este error aparece tarde o temprano. Quieres animar el color y el movimiento, así que escribes:

<!-- Mal: solo se anima una de las dos cosas -->
<button class="transition-colors transition-transform hover:scale-105 hover:bg-indigo-700">Comprar</button>

Las dos clases fijan la misma propiedad, transition-property, y una sustituye a la otra. Comprobado en el navegador, la propiedad calculada de ese botón es transform, translate, scale, rotate: el color cambia de golpe. Las soluciones son transition, que ya incluye ambas cosas, o una lista propia:

<button class="transition hover:scale-105 hover:bg-indigo-700">Comprar</button>
<button class="transition-[background-color,scale] hover:scale-105 hover:bg-indigo-700">Comprar</button>

¿Por qué no transition-all?

transition-all parece la solución cómoda, pero tiene tres problemas:

  1. Anima lo que no querías. Si con un punto de ruptura cambia el padding, o al pasar a modo oscuro cambia un color, también se anima.
  2. Puede animar propiedades caras, como width o height, sin que te des cuenta.
  3. El navegador tiene que vigilar todas las propiedades del elemento.

Úsala cuando de verdad cambien muchas propiedades a la vez, como en un popover (sección 9). Para lo demás, transition, transition-colors o una lista propia dicen mejor lo que quieres.

Valores arbitrarios

Con corchetes eliges exactamente qué se anima. Separa las propiedades con comas y sin espacios:

<div class="transition-[translate,box-shadow] duration-300 hover:-translate-y-1 hover:shadow-xl">...</div>

4. Duración, retardo y curva

duration-*

Indica cuánto tarda la transición, en milisegundos:

<div class="transition duration-150">Rápido</div>
<div class="transition duration-300">Normal</div>
<div class="transition duration-[1234ms]">Valor arbitrario</div>

Como referencia para no pasarte: 100-200 ms para botones y estados pequeños, 200-300 ms para tarjetas y menús, y hasta 500 ms para movimientos grandes en pantalla. Por encima de eso, la interfaz empieza a parecer lenta, sobre todo en acciones que el usuario repite a menudo.

Valores dinámicos de tiempo 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) solo existen los valores de la escala (duration-75, 100, 150, 200, 300, 500, 700, 1000, y lo mismo en delay-*) y duration-(--x) no se genera. Si usas la LTS: escribe duration-[1234ms], delay-[77ms] o duration-[var(--x)], que funcionan en las dos versiones.

En v4, duration-1234 y delay-77 funcionan sin corchetes, igual que los valores dinámicos del resto de utilidades. Para leer el tiempo de una variable se usa la sintaxis con paréntesis, duration-(--tiempo).

delay-*

Espera un tiempo antes de empezar. Sirve para que varias cosas no lleguen a la vez. En este ejemplo y en el siguiente, data-on: es una variante de datos que representa “el elemento se ha activado”: en tu proyecto puede ser un atributo que pongas con JavaScript, hover: o cualquier otro estado.

<div class="flex gap-2">
  <div class="caja transition-[opacity,translate] duration-300 translate-y-3 opacity-0 data-on:translate-y-0 data-on:opacity-100">0</div>
  <div class="caja transition-[opacity,translate] delay-100 duration-300 translate-y-3 opacity-0 data-on:translate-y-0 data-on:opacity-100">100</div>
  <div class="caja transition-[opacity,translate] delay-200 duration-300 translate-y-3 opacity-0 data-on:translate-y-0 data-on:opacity-100">200</div>
</div>

Tres cajas que aparecen una detrás de otra: a los 100 ms solo se ve la primera y a los 500 ms están las tres

Figura 4. Cada caja tiene 100 ms más de retardo que la anterior.

⚠️ Importante: delay-* también se aplica al volver. Un menú con delay-200 tarda 200 ms en cerrarse, y eso se nota como lentitud. Si solo quieres retardo al entrar, ponlo en la variante: hover:delay-200.

ease-*: la curva de aceleración

Un objeto real no arranca a toda velocidad ni frena en seco. La curva de aceleración, llamada easing, decide cómo se reparte el avance a lo largo del tiempo.

Cinco gráficas de curvas: linear es una recta, ease-in empieza plana, ease-out termina plana, ease-in-out tiene forma de S y muelle se pasa del valor final

Figura 5. Las cuatro curvas de Tailwind y una propia, con su uso típico.

Clase Valor Cuándo usarla
ease-linear linear Giros continuos y barras de progreso
ease-in cubic-bezier(0.4, 0, 1, 1) Lo que sale de pantalla: acelera al irse
ease-out cubic-bezier(0, 0, 0.2, 1) Lo que entra: llega rápido y frena
ease-in-out cubic-bezier(0.4, 0, 0.2, 1) Lo que se mueve dentro de la pantalla

Todas las cajas de la figura siguiente tienen la misma duración de 1000 ms y están congeladas en el mismo instante, a los 500 ms. A mitad de tiempo, cada una ha recorrido una distancia distinta:

<div class="w-24 caja transition-transform duration-1000 ease-linear data-on:translate-x-72">linear</div>
<div class="w-24 caja transition-transform duration-1000 ease-in data-on:translate-x-72">ease-in</div>
<div class="w-24 caja transition-transform duration-1000 ease-out data-on:translate-x-72">ease-out</div>
<div class="w-24 caja transition-transform duration-1000 ease-in-out data-on:translate-x-72">in-out</div>
<div class="w-24 caja transition-transform duration-1000 ease-muelle data-on:translate-x-72">muelle</div>

A los 500 ms ease-in va la última, linear va por la mitad, ease-out y ease-in-out van más adelantadas y muelle casi ha llegado al final

Figura 6. Cinco curvas en el mismo instante: a mitad del tiempo, ease-in apenas ha avanzado y muelle casi ha llegado.

Curvas y tiempos propios 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe @theme ni ease-(--x). Si usas la LTS: define la curva en theme.extend.transitionTimingFunction de tailwind.config.js, y la duración por defecto en theme.extend.transitionDuration.DEFAULT. Para un valor suelto, ease-[cubic-bezier(0.34,1.56,0.64,1)] funciona en las dos versiones.

Una curva propia es una variable del tema con el prefijo --ease-. La ease-muelle de los ejemplos sale de aquí:

@theme {
  --ease-muelle: cubic-bezier(0.34, 1.56, 0.64, 1);
  --default-transition-duration: 200ms;
  --default-transition-timing-function: var(--ease-out);
}

El segundo número de cubic-bezier mayor que 1 es lo que hace que el valor se pase y vuelva. --default-transition-duration cambia los 150 ms de todas las clases transition-*. Verás @theme a fondo en el módulo 14.

El equivalente en Tailwind 3.4:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      transitionTimingFunction: { muelle: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
      transitionDuration: { DEFAULT: '200ms' },
    },
  },
};

--tw-duration y las variantes 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) cada transition-* fija sus 150 ms directamente, y una clase con variante puede pisar tu duration-*: con duration-700 hover:transition-colors, medido en el navegador, la duración en hover es 0.15s. Si usas la LTS: repite la duración en la variante, hover:duration-700.

En v4, duration-300 guarda además su valor en una variable:

.duration-300 {
  --tw-duration: 300ms;
  transition-duration: 300ms;
}

Las clases transition-* leen esa variable con var(--tw-duration, ...), de modo que una transition-* no pisa nunca una duración explícita. Es un detalle pequeño que evita un error difícil de encontrar.

duration-* no afecta a las animaciones

duration-*, delay-* y ease-* solo actúan sobre transiciones. Este elemento conserva su animación tal como es:

<div class="animate-bounce duration-700 ease-out delay-500">No cambia nada</div>

Medido en Chromium: animation-duration sigue en 1s y animation-delay en 0s. La sección 8 explica cómo cambiarlos.


5. Transformaciones en 2D

Las transformaciones mueven, giran o deforman un elemento sin alterar el diseño de la página: su hueco original se conserva. Son la base de casi todo lo que se anima.

<div class="caja size-14 translate-x-4 -translate-y-2">A</div>
<div class="caja size-14 rotate-12">A</div>
<div class="caja size-14 scale-125">A</div>
<div class="caja size-14 skew-x-12">A</div>

Cuatro cajas: una desplazada, otra girada, otra ampliada y otra inclinada

Figura 7. Las cuatro transformaciones básicas. En translate-x-4, la caja se desplaza respecto al centro de su marco.

Familia Ejemplos Qué hace
translate-* translate-x-4, -translate-y-2, translate-x-full, translate-x-1/2 Desplaza. Los porcentajes son del propio elemento
rotate-* rotate-45, -rotate-12, rotate-[17deg] Gira en grados
scale-* scale-110, scale-x-50, -scale-x-100 Amplía o reduce. -scale-x-100 hace de espejo
skew-* skew-x-12, skew-y-6 Inclina
origin-* origin-center, origin-top-left, origin-[25%_75%] Mueve el punto fijo de la transformación

translate-x-1/2 y -translate-y-1/2 son el truco clásico para centrar un elemento absoluto de tamaño desconocido, porque ese 50 % se mide sobre el propio elemento y no sobre su padre.

origin-*

Toda transformación tiene un punto que no se mueve. Por defecto es el centro. Si lo cambias, el mismo rotate-45 produce resultados distintos:

<div class="caja size-16 origin-center rotate-45"></div>
<div class="caja size-16 origin-top-left rotate-45"></div>
<div class="caja size-16 origin-bottom rotate-45"></div>
<div class="caja size-16 origin-[25%_75%] rotate-45"></div>

Cuatro cuadrados girados 45 grados sobre puntos distintos, con la posición original marcada con una línea discontinua

Figura 8. La línea discontinua es la posición original. Solo cambia el punto fijo.

origin-* es lo que hace posible animaciones como un subrayado que crece desde la izquierda o un menú que se despliega desde su botón: un scale-x-0 con origin-left crece hacia la derecha, en lugar de hacerlo desde el centro.

Valores dinámicos en transformaciones 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) solo existen los valores de la escala (rotate-45, rotate-90, scale-110) y no están rotate-none, scale-none ni translate-none. Si usas la LTS: escribe rotate-[17deg] o scale-[1.15], y para anular usa rotate-0, scale-100 y translate-x-0.

En v4 funcionan rotate-17 y scale-115 sin corchetes.

Un error habitual: elementos en línea

Las transformaciones no se aplican a los elementos en línea, como un <span> o un <a>:

<span class="rotate-45">No gira</span>
<span class="inline-block rotate-45">Sí gira</span>

Medido con getBoundingClientRect(): el primero sigue midiendo 47 × 21 píxeles, igual que sin rotar. El segundo mide 50 × 50, porque ya gira. Añade inline-block, block o inline-flex al elemento.


6. Propiedades individuales 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) todas las transformaciones se escriben en transform, y transform-none las anula todas a la vez. Si usas la LTS: las diferencias de este apartado no te afectan: transition-[transform] y transform-none funcionan como se esperaría, pero una animación con transform sustituye a tus transformaciones.

Esta es la novedad de v4 con más consecuencias del módulo. Compara lo que genera rotate-45 en cada versión:

/* Tailwind 3.4 */
.rotate-45 {
  --tw-rotate: 45deg;
  transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate))
    skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}

/* Tailwind 4.3.3 */
.rotate-45 {
  rotate: 45deg;
}

CSS tiene desde hace unos años tres propiedades independientes, translate, rotate y scale, y v4 las usa. Solo van por transform las que no tienen propiedad propia: rotate-x-*, rotate-y-*, rotate-z-* y skew-*.

Arriba, Tailwind 3.4 con todo en transform. Abajo, Tailwind 4 con cuatro propiedades en cadena: translate, rotate, scale y transform

Figura 9. En v4 cada transformación vive en su propia propiedad, y el navegador las combina siempre en el mismo orden.

La ventaja es que puedes animar una sin afectar a las demás. Las consecuencias prácticas son cuatro, y las cuatro las hemos medido.

1. transition-[transform] ya no anima scale-*

<!-- Copiado de un proyecto v3: en v4 salta de golpe -->
<div class="transition-[opacity,transform] hover:scale-110">...</div>

<!-- Correcto en v4 -->
<div class="transition-[opacity,scale] hover:scale-110">...</div>

Al pasar el ratón, el primer elemento tiene 0 animaciones activas y el segundo tiene 1. scale-110 cambia la propiedad scale, y esa no está en la lista. transition-transform no da este problema porque en v4 incluye las cuatro propiedades.

⚠️ Importante: Un repaso visual puede dejar pasar este error, porque el estado final es correcto: solo falta el camino. Busca transition-[ en tu proyecto al migrar. Lo veremos en el módulo 19.

2. transform-none no anula rotate-45

<div class="rotate-45 transform-none">Sigue girado 45 grados</div>
<div class="rotate-45 rotate-none">Ya no gira</div>

transform-none solo pone transform: none, pero el giro está en otra propiedad. Medido: la propiedad rotate calculada del primero sigue siendo 45deg, y la del segundo es none. Usa rotate-none, scale-none y translate-none.

También sobra la clase transform, que era obligatoria en versiones muy antiguas de Tailwind. Sigue generándose, pero no la necesitas.

3. Una animación con transform ya no borra tus transformaciones

<div class="rotate-45 animate-bounce">Rebota girado</div>

animate-bounce escribe en transform. En v3 el giro también está en transform, así que la animación lo sustituye mientras dura: medido, la matriz calculada no tiene rotación. En v4 el giro está en rotate y la animación en transform, de modo que las dos conviven: rotate sigue en 45deg mientras la matriz recoge el rebote.

Si lo necesitas en v3, envuelve el elemento: el rebote en el padre y el giro en el hijo.

4. El orden de aplicación cambia

El orden es fijo: translate, rotate, scale y por último transform. No depende del orden de las clases en el HTML. La primera consecuencia es que Tailwind siempre desplaza antes de girar, así que el desplazamiento no gira con el elemento:

<div class="caja size-14 translate-x-10 rotate-45">A</div>
<div class="caja size-14 [transform:rotate(45deg)_translateX(2.5rem)]">B</div>

La caja A se desplaza en horizontal. La caja B se desplaza en diagonal, porque sus ejes ya estaban girados

Figura 10. En A el desplazamiento es horizontal. En B se gira primero y el desplazamiento sigue los ejes girados.

Para controlar el orden, escribe transform con un valor arbitrario: las funciones de transform se aplican en el orden en que las escribes, como en B. Es exactamente lo que necesitaremos en el cubo de la sección 13.

La segunda consecuencia afecta a quien combina scale-* y skew-*, porque en v3 la escala iba después del skew en la lista y en v4 va en otra propiedad. Con h-10 w-40 scale-x-50 skew-x-[45deg] medimos una caja envolvente de 120 × 40 en v3 y de 100 × 40 en v4. Si tu diseño depende de ese detalle, compruébalo al migrar.


7. Animaciones predefinidas

Tailwind trae cuatro animaciones, y las cuatro resuelven una necesidad real:

<!-- Spinner en un botón -->
<button disabled class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-white">
  <svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z"></path>
  </svg>
  Guardando
</button>

<!-- Aviso que llama la atención -->
<span class="relative inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-2">
  Pedidos
  <span class="absolute -top-1 -right-1 flex size-3">
    <span class="absolute inline-flex size-full animate-ping rounded-full bg-orange-400 opacity-75"></span>
    <span class="relative inline-flex size-3 rounded-full bg-orange-500"></span>
  </span>
</span>

<!-- Esqueleto de carga -->
<div class="animate-pulse space-y-2" aria-hidden="true">
  <div class="h-12 rounded bg-slate-300"></div>
  <div class="h-3 w-3/4 rounded bg-slate-300"></div>
</div>

<!-- Indicar que hay más abajo -->
<svg class="size-6 animate-bounce text-indigo-600" viewBox="0 0 24 24" fill="none" stroke="currentColor"
     stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
  <path d="M12 5v14M5 12l7 7 7-7"/>
</svg>

Cuatro recuadros: botón con spinner, punto de aviso, esqueleto de carga y flecha que rebota

Figura 11. Las cuatro animaciones de Tailwind en un instante de su ciclo.

Clase Valor @keyframes Uso
animate-spin spin 1s linear infinite Llega a rotate(360deg) Operación en curso
animate-ping ping 1s cubic-bezier(0, 0, 0.2, 1) infinite Crece a scale(2) y se desvanece Notificación nueva
animate-pulse pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite Baja a opacity: 0.5 y vuelve Contenido cargando
animate-bounce bounce 1s infinite Sube un 25 % y cae Invitar a desplazarse
animate-none none   Quitar la animación

Tres detalles de estos ejemplos que merece la pena copiar:

  • animate-ping necesita dos elementos. Lo que se expande y desaparece es una copia absoluta. El punto fijo va debajo, porque si no, el aviso desaparecería en cada ciclo.
  • animate-spin se aplica al icono, no al botón, para que no gire el texto.
  • El esqueleto lleva aria-hidden="true", porque unas barras grises no significan nada para un lector de pantalla. Avisa de la carga con aria-busy o con un role="status".

Todas se pueden aplicar con una variante, como group-hover:animate-bounce. Una animación empieza cuando se aplica la clase, así que al salir del hover se corta en seco en lugar de terminar. Para movimientos que respondan al ratón, una transición queda mejor.


8. Animaciones propias

@keyframes dentro de @theme 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe @theme. Si usas la LTS: define la animación en theme.extend.animation y sus pasos en theme.extend.keyframes de tailwind.config.js, como en el ejemplo de abajo. La clase animate-entrar que obtienes es la misma.

Para crear animate-entrar hacen falta dos cosas: una variable --animate-entrar y sus @keyframes. Se escriben juntas dentro de @theme:

@theme {
  --animate-entrar: entrar 0.4s ease-out both;

  @keyframes entrar {
    from {
      opacity: 0;
      transform: translateY(1rem);
    }
    to {
      opacity: 1;
      transform: none;
    }
  }
}

El valor de la variable es el de la propiedad animation de CSS: nombre, duración, curva y, si hace falta, retardo, repeticiones y modo de relleno.

El mismo ejemplo en Tailwind 3.4, comprobado con la versión 3.4.19:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      animation: { entrar: 'entrar 0.4s ease-out both' },
      keyframes: {
        entrar: {
          from: { opacity: '0', transform: 'translateY(1rem)' },
          to: { opacity: '1', transform: 'none' },
        },
      },
    },
  },
};

💡 Consejo: Tailwind solo incluye los @keyframes de @theme si usas su clase. En nuestro laboratorio definimos un cuarto @keyframes, sin-usar, sin ninguna clase que lo use, y no llegó a la hoja de estilos final. Si escribes los @keyframes fuera de @theme, se conservan siempre.

El modo de relleno

La palabra both no es decorativa. Una animación solo se aplica mientras dura: antes de empezar y al terminar, el elemento vuelve a sus estilos normales. both hace que tome el estado del primer fotograma mientras espera y que conserve el último al acabar.

Valor Efecto
Sin indicar El elemento se ve normal durante el retardo y vuelve a su estado al terminar
forwards Conserva el último fotograma al acabar
backwards Toma el primer fotograma durante el retardo
both Las dos cosas

Sin both, un elemento con retardo se vería entero, desaparecería de golpe al empezar la animación y volvería a entrar: un parpadeo muy feo.

Entradas escalonadas

Para que cada tarjeta entre un poco después que la anterior se usa animation-delay. Como delay-* no sirve aquí, se recurre a una propiedad arbitraria:

<div class="flex gap-2">
  <div class="caja w-24 animate-entrar">Portátil</div>
  <div class="caja w-24 animate-entrar [animation-delay:120ms]">Torre</div>
  <div class="caja w-24 animate-entrar [animation-delay:240ms]">Monitor</div>
</div>

Cinco fotogramas: a los 150 ms ya se ve el primer producto, y a los 640 ms los tres están en su sitio

Figura 12. Las tres cajas entran con 120 ms de diferencia. Gracias a both, las que aún no han empezado permanecen invisibles.

Si generas la lista desde JavaScript, es más cómodo pasar el retardo con style:

tarjeta.style.animationDelay = `${indice * 80}ms`;

Lo mismo sirve para cambiar otros datos de una animación ya definida:

<!-- Un spinner más lento -->
<svg class="animate-spin [animation-duration:2s]">...</svg>

<!-- La animación completa, escrita en la clase -->
<div class="animate-[spin_2s_linear_infinite]">...</div>

Dentro de los corchetes, el guion bajo representa un espacio. Si vas a repetir la animación, mejor dale un nombre en el tema.

Un ejemplo con varios pasos

Agitar un campo es una forma clásica de señalar un error:

@theme {
  --animate-agitar: agitar 0.4s ease-in-out;

  @keyframes agitar {
    0%, 100% { transform: translateX(0); }
    20%, 60% { transform: translateX(-0.5rem); }
    40%, 80% { transform: translateX(0.5rem); }
  }
}
<div class="animate-agitar rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800">
  La contraseña no es correcta
</div>

Cuatro fotogramas del aviso desplazándose a izquierda y derecha

Figura 13. El aviso oscila dos veces y vuelve a su sitio en 400 ms.

Una animación no se repite aunque vuelvas a poner la misma clase. Para repetirla desde JavaScript, quita la clase, fuerza un recálculo y vuelve a ponerla:

aviso.classList.remove('animate-agitar');
void aviso.offsetWidth;   // obliga al navegador a aplicar el cambio
aviso.classList.add('animate-agitar');

9. Entrar y salir de la página: starting: y transition-discrete

Hasta ahora, un elemento recién añadido a la página no podía hacer una transición: para que haya transición hace falta un estado anterior, y un elemento nuevo no lo tiene. Lo mismo pasaba con display: none, que además no se puede interpolar. Por eso los menús y modales necesitaban JavaScript o bibliotecas. El CSS moderno resuelve las dos cosas.

starting: y @starting-style 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa la variante arbitraria [@starting-style]:opacity-0, que genera el mismo CSS y hemos comprobado que funciona en 3.4.19. Otra opción es una animación propia con animate-entrar.

La variante starting: indica de dónde parte el elemento la primera vez que se muestra:

<div class="rounded-lg bg-slate-900 p-3 text-sm text-white shadow-lg
            transition duration-300 ease-out starting:translate-y-4 starting:opacity-0">
  Producto añadido al carrito
</div>
@starting-style {
  .starting\:opacity-0 {
    opacity: 0%;
  }
}

Cuatro fotogramas: el aviso aparece desde abajo y pasa de invisible a opaco en 300 ms

Figura 14. Un aviso añadido al DOM entra sin JavaScript de animación.

Este aviso se puede crear con document.body.append(aviso) y se anima solo. Funciona también cuando un elemento pasa de display: none a visible. Comparada con una animación, tiene una ventaja: como es una transición, si se interrumpe a medias, vuelve con suavidad desde donde esté.

📘 Concepto: @starting-style no es un estado que se vea: es el valor del que parte la transición en el primer fotograma. Por eso necesita siempre transition-* en el mismo elemento. Sin transición, no ocurre nada.

Según los datos de compatibilidad de MDN, @starting-style está disponible desde Chrome 117, Firefox 129 y Safari 17.5. En un navegador sin soporte, el elemento simplemente aparece sin animación, que es un buen resultado.

transition-discrete 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe [transition-behavior:allow-discrete], que hemos comprobado en 3.4.19: la salida de un elemento con hidden se anima igual.

Algunas propiedades, como display, no tienen valores intermedios: no existe “medio block”. Se llaman discretas y por defecto cambian de golpe. transition-discrete genera transition-behavior: allow-discrete, que permite incluirlas en la transición: display se queda en su valor visible hasta el final de la transición al ocultar.

Probemos con un popover (ya visto en el módulo 11) con y sin esa clase:

<button popovertarget="confirmacion">Abrir</button>

<div id="confirmacion" popover
     class="m-auto rounded-xl bg-white p-6 opacity-0 shadow-xl
            transition-all transition-discrete duration-200
            open:opacity-100 starting:open:opacity-0">
  Pedido confirmado
</div>
Medido en Chromium Con transition-discrete Sin ella
Transiciones al abrir 1 (opacidad a medias a los 60 ms) 1 (igual)
Transiciones al cerrar display, opacity, overlay Ninguna
display a los 60 ms de cerrar block, opacidad 0,54 none de golpe

La entrada funciona en ambos casos, gracias a starting:. La salida es lo que necesita transition-discrete. overlay entra en juego porque un popover vive en la capa superior del navegador y debe permanecer allí mientras se desvanece.

Tres cajas en fila: punto de partida con starting:open, abierto con open y cerrado con las clases sin variante

Figura 15. Un elemento que entra y sale necesita tres juegos de clases.

La lista de transition y transition-all ya incluye display y overlay. Con transition-opacity no funcionaría, porque limitas las propiedades a opacity.

El mismo patrón sirve para <dialog> y su fondo:

<dialog id="ficha"
        class="m-auto rounded-xl bg-white p-6 opacity-0
               transition-all transition-discrete duration-200
               open:opacity-100 starting:open:opacity-0
               backdrop:bg-black/0 backdrop:transition-all backdrop:transition-discrete backdrop:duration-200
               open:backdrop:bg-black/50 starting:open:backdrop:bg-black/0">
  Ficha del producto
</dialog>

También para un panel propio que alternes con un atributo o una clase:

<div class="hidden opacity-0 transition-all transition-discrete duration-200
            data-on:block data-on:opacity-100 starting:data-on:opacity-0">
  Panel de filtros
</div>

Según MDN, transition-behavior está disponible desde Chrome 117, Firefox 129 y Safari 17.4. Las clases starting: y transition-discrete son, pues, una mejora progresiva: sin soporte, el elemento se abre y cierra sin animar.

⚠️ Importante: Recuerda la lección del módulo 11: open: en Tailwind 3.4 es solo [open], sin :popover-open. Para esta técnica en v3 tendrías que usar la variante arbitraria [&:popover-open]:opacity-100.

Alturas automáticas: el acordeón

Otro caso típico. Quieres que un panel se despliegue, y escribes esto:

<!-- No se anima: auto no es un número -->
<div class="h-0 overflow-hidden transition-[height] duration-300 data-on:h-auto">...</div>

Medido: 0 animaciones activas. Con data-on:h-20 hay 1. El navegador no sabe interpolar entre 0 y auto, porque auto no es una longitud. El truco que funciona en todos los navegadores modernos es animar una fila de Grid: las unidades fr sí se interpolan, y 1fr se adapta al contenido.

<div class="rounded-lg border border-slate-200">
  <div class="px-3 py-2 text-sm font-medium">Envío y devoluciones</div>
  <div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 data-on:grid-rows-[1fr]">
    <div class="overflow-hidden">
      <p class="px-3 pb-2 text-sm text-slate-600">Los pedidos salen en 24 horas y tienes 30 días para devolverlos.</p>
    </div>
  </div>
</div>

Cuatro fotogramas de un panel que se despliega sin conocer su altura

Figura 16. El panel se abre hasta la altura de su contenido, sin indicar ninguna cifra.

El hijo con overflow-hidden es imprescindible: es el que recorta el contenido mientras la fila es más baja que él. Fíjate en que la altura sí es una propiedad cara, aquí es inevitable, y por eso conviene reservar este truco a paneles sueltos y no a listas largas.


10. Transiciones en una interfaz real

Tarjeta de producto

Reunamos lo visto en una tarjeta de la tienda EMPRESA. Se eleva, su sombra crece, y la imagen se amplía dentro de un marco que la recorta:

<article class="group w-56 overflow-hidden rounded-xl bg-white shadow-sm
                transition duration-300 ease-out
                hover:-translate-y-1 hover:shadow-xl
                motion-reduce:transition-none motion-reduce:hover:translate-y-0">
  <div class="overflow-hidden">
    <div class="flex h-32 items-center justify-center bg-linear-to-br from-indigo-500 to-sky-400
                text-3xl font-bold text-white
                transition-transform duration-500 ease-out group-hover:scale-110
                motion-reduce:transition-none motion-reduce:group-hover:scale-100">14"</div>
  </div>
  <div class="p-4">
    <h3 class="text-sm font-semibold text-slate-900">Portátil UltraBook 14</h3>
    <p class="mt-1 text-lg font-bold text-indigo-700">899 €</p>
    <button class="mt-3 w-full rounded-lg bg-indigo-600 py-2 text-sm font-medium text-white
                   transition-colors duration-200 hover:bg-indigo-700 active:scale-95">
      Añadir al carrito
    </button>
  </div>
</article>

La tarjeta en reposo, con sombra pequeña

Figura 17. La tarjeta en reposo.

La misma tarjeta con hover: está más alta, con sombra grande y la imagen ampliada

Figura 18. Con hover: sube 4 píxeles, la sombra crece y la imagen se amplía dentro de su marco.

Fíjate en estas decisiones:

  • overflow-hidden en el envoltorio de la imagen. Sin él, la imagen ampliada se saldría de la tarjeta.
  • transition en la tarjeta y transition-transform en la imagen. Cada elemento anima solo lo suyo y con su propio tiempo.
  • group y group-hover: (módulo 11), para que la imagen reaccione al pasar por cualquier parte de la tarjeta.
  • active:scale-95 en el botón. Es un detalle que da sensación de pulsación. Aquí, sin embargo, hay un descuido a propósito: el botón tiene transition-colors, de modo que la escala salta sin animar. Con transition quedaría suave. Lo arreglarás en el ejercicio 1.

El parpadeo en hover

Si el hover mueve el elemento fuera del puntero, se produce un bucle: el elemento se aparta, deja de estar bajo el ratón, pierde el hover, vuelve, recupera el hover…

<!-- Parpadea si el puntero está en el borde izquierdo -->
<div class="w-24 transition duration-200 hover:translate-x-40">...</div>

Lo hemos reproducido: con el ratón quieto en el borde izquierdo, :hover va alternando entre true y false. La solución es separar la zona que detecta el puntero de la que se mueve. El padre no se mueve y conserva el hover:

<div class="group w-24">
  <div class="transition duration-200 group-hover:translate-x-40">...</div>
</div>

Volveremos a esta idea en la tarjeta 3D, donde es imprescindible.


11. Movimiento reducido: motion-safe y motion-reduce

Para algunas personas, sobre todo con trastornos vestibulares, las animaciones grandes producen mareo o náuseas. Los sistemas operativos tienen un ajuste para pedir menos movimiento (en Windows, “Efectos de animación”), y CSS lo expone con la consulta prefers-reduced-motion. Tailwind ofrece dos variantes:

<!-- motion-reduce: se aplica si el usuario pidió menos movimiento -->
<div class="transition hover:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0">...</div>

<!-- motion-safe: solo se aplica si NO lo pidió -->
<div class="motion-safe:animate-bounce">...</div>
@media (prefers-reduced-motion: no-preference) {
  .motion-safe\:animate-bounce { animation: var(--animate-bounce); }
}
@media (prefers-reduced-motion: reduce) {
  .motion-reduce\:transition-none { transition-property: none; }
}

Lo hemos comprobado con Playwright, que permite simular la preferencia. Con reducedMotion: 'reduce', un elemento con motion-safe:animate-bounce motion-reduce:transition-none transition da animation-name: none y transition-property: none. Sin esa preferencia da bounce y la lista completa. En Chrome puedes probarlo sin tocar el sistema: herramientas de desarrollo, panel Rendering, “Emulate CSS media feature prefers-reduced-motion”.

Qué conviene reducir

“Movimiento reducido” no significa “ninguna animación”. Lo que molesta es el desplazamiento grande, no un cambio de color:

Efecto Con movimiento reducido
Cambio de color, opacidad, sombra Se puede mantener
Elevación pequeña en hover Mejor eliminar el desplazamiento
Entradas con desplazamiento, parallax Eliminar, o sustituir por un fundido
Rotaciones 3D, cubos, giros de tarjeta Eliminar
Spinner de carga Mantener: informa de algo. Se puede ralentizar
animate-pulse, animate-bounce decorativos Eliminar

Hay una trampa con motion-reduce:transition-none: la propiedad deja de animarse, pero sigue cambiando. Por eso los ejemplos llevan también motion-reduce:hover:translate-y-0: la tarjeta deja de saltar, en vez de saltar sin suavizar.

💡 Consejo: Para las animaciones que no aportan información, motion-safe: es más seguro que motion-reduce:animate-none, porque por defecto no se anima nada y lo activas solo si no hay preferencia en contra. Si te olvidas de añadir la variante, el fallo es visible y lo notas en seguida.


12. Rendimiento y depuración

will-change y transform-gpu

will-change-transform avisa al navegador de que algo va a animarse, para que lo suba a su propia capa antes de empezar. Parece una mejora gratuita, y no lo es: cada capa ocupa memoria de la tarjeta gráfica. Las clases disponibles son will-change-auto, will-change-scroll, will-change-contents y will-change-transform.

<!-- Solo mientras haga falta, no en toda la lista -->
<div class="transition-transform hover:will-change-transform hover:scale-105">...</div>

Úsalo únicamente si has detectado saltos en esa animación concreta, y no en cientos de elementos.

transform-gpu tiene una historia parecida: añade translateZ(0) a transform para forzar una capa propia, y transform-cpu lo quita. Hoy el navegador crea sola esa capa cuando anima transform u opacity, de modo que casi nunca hacen falta.

Depurar con las herramientas de desarrollo

En Chrome, abre el menú de tres puntos del panel inferior, More tools y Animations. El panel registra cada transición y animación con su línea de tiempo, y te deja ralentizarlas al 25 % o al 10 %, pausarlas y arrastrar el tiempo a mano. Si una transición no aparece ahí, es que no se ha creado.

Desde la consola puedes preguntar directamente:

const el = document.querySelector('#tarjeta');
el.getAnimations().map((a) => a.transitionProperty ?? a.animationName);
// Con la transición en marcha: ['box-shadow', 'translate']

Con esa llamada se han medido las transiciones de este módulo. document.getAnimations() hace lo mismo para toda la página.

Tabla de diagnóstico

Síntoma Causa probable Solución
El cambio es instantáneo Falta transition-*, o la propiedad no está en la lista Añade la clase, o amplía la lista
scale-* salta en v4 transition-[transform] Usa transition-[scale] o transition-transform
Solo se anima al entrar transition puesta en hover: Ponla en el estado normal
Se anima una sola cosa Dos clases transition-* Usa transition o una lista propia
duration-* no cambia un animate-* duration-* es para transiciones [animation-duration:2s]
delay-* no retrasa un animate-* Igual que el anterior [animation-delay:200ms]
rotate-* no hace nada Elemento en línea Añade inline-block
No se anima una altura auto no se interpola Fila de Grid con fr
El elemento salta al terminar la animación Falta el modo de relleno Añade both o forwards a la animación
Parpadeo en hover El elemento se aparta del puntero Detecta el hover en un padre fijo
El cierre del popover no se anima Falta transition-discrete Añádela, junto a transition o transition-all
No se ve el 3D Falta perspective-* en el padre Sección 13

13. Tres dimensiones 🆕 v4.0

🆕 Desde Tailwind 4.0. Las clases de esta sección no existen en la LTS (Tailwind 3.4). Si usas la LTS: escribe lo mismo con propiedades arbitrarias: [perspective:300px], [transform-style:preserve-3d], [backface-visibility:hidden] y [transform:rotateY(180deg)]. La última parte de la sección trae el ejemplo completo.

Una página web es plana, pero el navegador puede colocar elementos en un espacio de tres dimensiones y dibujar cómo se verían desde un punto de vista. Es muy útil para detalles como una tarjeta que gira, y hay que usarlo con moderación.

Los tres ejes

Ejes X, Y y Z sobre la pantalla: X crece a la derecha, Y hacia abajo y Z hacia el espectador

Figura 19. El eje Y crece hacia abajo, al revés que en matemáticas. Por eso rotate-x positivo aleja la parte de arriba.

Clase Propiedad generada Efecto
rotate-x-*, -rotate-x-* transform Vuelca hacia atrás o hacia delante
rotate-y-*, -rotate-y-* transform Gira como una puerta
rotate-z-* transform Gira en el plano, como rotate-*
translate-z-*, -translate-z-* translate Acerca o aleja. Usa la escala de espaciado
translate-z-px translate 1 píxel
scale-z-* scale Escala en profundidad
translate-3d, scale-3d translate, scale Activan las tres dimensiones de valores ya puestos

Los porcentajes no sirven en el eje Z: translate-z-1/2 no genera nada, porque un elemento plano no tiene profundidad de la que sacar un porcentaje.

<div class="flex h-36 items-center justify-center perspective-near">
  <div class="caja size-20 rotate-x-55">Pedido</div>
</div>

Cuatro cajas: rotate-x-55 volcada, rotate-y-55 como una puerta, rotate-z-55 girada en el plano y translate-z-16 más grande

Figura 20. Giros en los tres ejes, más translate-z-16, que acerca la caja hacia ti y por eso se ve mayor.

Sin perspectiva, el 3D se ve plano

Si pruebas rotate-y-60 a secas, solo verás una caja más estrecha, como si la hubieras aplastado. El giro existe, pero se dibuja sin profundidad.

A la izquierda, un rectángulo simple estrechado. A la derecha, el mismo giro con perspectiva, con un lado más alto que el otro

Figura 21. Mismo rotate-y-60. Solo con perspective-* en el padre hay sensación de profundidad.

perspective-* indica a qué distancia está tu ojo de la pantalla. Lo que se acerca al ojo se ve más grande, y lo que se aleja, más pequeño:

Vista lateral: con el ojo a 300 píxeles el elemento girado ocupa un ángulo muy distinto en cada extremo; con el ojo a 1200 píxeles la diferencia es pequeña

Figura 22. Con el ojo cerca, la diferencia entre el extremo cercano y el lejano es grande. Con el ojo lejos, casi desaparece.

Clase Valor Sensación
perspective-dramatic 100px Muy deformado, casi ojo de pez
perspective-near 300px Marcada; para tarjetas pequeñas
perspective-normal 500px Equilibrada
perspective-midrange 800px Discreta
perspective-distant 1200px Muy sutil
perspective-none none Sin perspectiva
perspective-[1000px] La que indiques  

Cinco cajas con rotate-x-45 y distancia creciente: la primera es un trapecio muy marcado y la última casi un rectángulo

Figura 23. El mismo rotate-x-45 con las cinco perspectivas de Tailwind.

Para una tarjeta de tamaño normal, entre near y normal suele ir bien. La distancia es absoluta, así que 300 píxeles es mucho para una caja de 80 y muy poco para un bloque de 800.

Quién lleva la perspectiva

perspective-* se pone en el padre y afecta a los hijos, no al elemento que gira. Y dónde la pongas cambia el resultado:

<!-- Un solo punto de vista para todos -->
<div class="flex justify-around perspective-near">
  <div class="caja size-24 rotate-y-45"></div>
  <div class="caja size-24 rotate-y-45"></div>
  <div class="caja size-24 rotate-y-45"></div>
</div>

<!-- Un punto de vista por tarjeta -->
<div class="flex justify-around">
  <div class="perspective-near"><div class="caja size-24 rotate-y-45"></div></div>
  <div class="perspective-near"><div class="caja size-24 rotate-y-45"></div></div>
  <div class="perspective-near"><div class="caja size-24 rotate-y-45"></div></div>
</div>

Arriba, tres cajas que se ven distintas según su posición. Abajo, tres cajas idénticas

Figura 24. Arriba hay una sola escena, y cada caja se ve desde un ángulo distinto. Abajo, cada caja tiene su propia escena.

Para una fila de tarjetas iguales suele quedar mejor un envoltorio por tarjeta. Para una escena única, como una galería, usa un solo contenedor.

perspective-origin-*

El punto de fuga está por defecto en el centro del contenedor. Lo mueves con perspective-origin-*, que acepta las posiciones habituales: center, top, bottom, left, right, sus combinaciones (top-left…) y valores arbitrarios.

<div class="perspective-near perspective-origin-top">
  <div class="caja size-20 rotate-x-45"></div>
</div>

Cuatro cajas con rotate-x-45 y distinto punto de fuga: se ven como desde arriba, abajo y un lado

Figura 25. Es como mover la cabeza: la caja no cambia, cambia desde dónde la miras.

transform-3d: los hijos conservan su profundidad

Por defecto, cuando un elemento tiene hijos transformados, los aplana contra su propio plano, como una pegatina. transform-3d genera transform-style: preserve-3d y le pide que deje a sus hijos en el espacio 3D:

<div class="flex items-center justify-center perspective-near">
  <div class="relative size-32 rotate-y-45 rounded-lg bg-indigo-200 transform-3d">
    <div class="caja absolute inset-6 translate-z-12">Hijo</div>
  </div>
</div>

Tres casos: sin transform-3d el hijo queda pegado al padre; con transform-3d flota delante; con opacity-90 vuelve a quedar pegado

Figura 26. Con transform-3d el hijo se separa de su padre. Con opacity-90 en el padre, se aplana de nuevo.

transform-flat devuelve el valor por defecto.

⚠️ Importante: Algunas propiedades fuerzan el aplanado aunque pongas transform-3d, y no dan ningún error. Medimos el tamaño visible de un hijo con translate-z-12, en un padre de 128 píxeles: con transform-3d da 158 × 101. Al añadir al padre overflow-hidden da 137 × 88; con un filtro blur-*, 137 × 88. Y con opacity-90 también se aplana, como se ve en la figura. Si la tarjeta 3D se queda plana, busca overflow-hidden, opacity-*, blur-* y similares en el elemento que lleva transform-3d. En sus hijos no dan problema.

Una tarjeta que gira

El uso más típico del 3D es una ficha con dos caras. Hacen falta tres niveles, y conviene aprenderlos en este orden:

<!-- 1. Escena: no se mueve, tiene la perspectiva y detecta el hover -->
<div class="group h-52 w-40 perspective-near">

  <!-- 2. Tarjeta: la que gira, y conserva el 3D de sus caras -->
  <div class="relative size-full transform-3d transition-transform duration-700 ease-in-out
              group-hover:rotate-y-180">

    <!-- 3a. Frente -->
    <div class="absolute inset-0 flex flex-col items-center justify-center rounded-xl
                bg-indigo-600 p-4 text-white backface-hidden">
      <span class="flex size-16 items-center justify-center rounded-full bg-white/20 text-2xl font-bold">AG</span>
      <h3 class="mt-3 font-semibold">Ana García</h3>
      <p class="text-sm text-indigo-100">Ventas</p>
    </div>

    <!-- 3b. Dorso: empieza girado 180 grados -->
    <div class="absolute inset-0 flex flex-col justify-center rounded-xl
                bg-slate-800 p-4 text-sm text-slate-100 rotate-y-180 backface-hidden">
      <p class="font-semibold">Contacto</p>
      <p class="mt-2">Extensión 214</p>
      <p>Madrid</p>
    </div>
  </div>
</div>

Cinco fotogramas: la tarjeta azul de Ana García gira, se ve de canto a los 250 ms y aparece el dorso oscuro con los datos de contacto

Figura 27. Giro de 700 ms. A mitad del recorrido la tarjeta está de canto y las caras se relevan.

Cada pieza responde a un motivo:

  • La escena no gira. Si el hover estuviera en el elemento que gira, al ponerse de canto quedaría casi sin anchura, el puntero dejaría de estar encima y volverías al parpadeo de la sección 10.
  • backface-hidden oculta la cara de atrás. Sin ella, se ve la capa que esté encima, y además reflejada, porque se mira desde detrás.
  • El dorso empieza en rotate-y-180, de espaldas a ti. Al girar el conjunto 180 grados, queda de frente.

Sin backface-hidden se ve el dorso con el texto invertido incluso al principio

Figura 28. Sin backface-hidden, el dorso tapa el frente desde el primer fotograma, con el texto al revés.

Clase CSS  
backface-hidden backface-visibility: hidden Invisible cuando está de espaldas
backface-visible backface-visibility: visible Valor por defecto

⚠️ Importante: En un móvil no hay hover, y en un teclado tampoco. En v4 hover: solo se activa en dispositivos que lo tienen, de modo que en táctil el dorso quedaría inaccesible. Añade también tabindex="0" a la escena y group-focus:rotate-y-180, o haz que gire con un botón y un atributo data-*. Nunca escondas información importante solo tras un hover.

Un cubo, y el error del orden

Vamos a por un cubo de 128 píxeles. En CSS puro se escribe transform: rotateY(90deg) translateZ(4rem) en cada cara: se gira la cara y se empuja hacia fuera por su propio eje Z, ya girado. Traducirlo clase a clase parece evidente, pero sale mal:

<!-- Mal -->
<div class="caja absolute inset-0 bg-orange-500/80 rotate-y-90 translate-z-16">D</div>

Recuerda el orden fijo de la sección 6: translate se aplica antes que transform. Da igual cómo escribas las clases. Todas las caras se empujan por el eje Z de la escena, sin girar, y luego cada una gira sobre su centro. El resultado son caras que se cruzan en el mismo punto.

La solución es pensar al revés: desplaza cada cara por el eje de la escena hacia donde deba quedar, y gírala allí.

<div class="flex h-56 w-56 items-center justify-center perspective-midrange">
  <div class="relative size-32 -rotate-x-25 rotate-y-35 transform-3d">
    <div class="caja absolute inset-0 bg-indigo-600/80 translate-z-16">F</div>
    <div class="caja absolute inset-0 bg-indigo-900/80 -translate-z-16 rotate-y-180">T</div>
    <div class="caja absolute inset-0 bg-orange-500/80 translate-x-16 rotate-y-90">D</div>
    <div class="caja absolute inset-0 bg-orange-800/80 -translate-x-16 -rotate-y-90">I</div>
    <div class="caja absolute inset-0 bg-teal-600/80 -translate-y-16 rotate-x-90">S</div>
    <div class="caja absolute inset-0 bg-teal-900/80 translate-y-16 -rotate-x-90">B</div>
  </div>
</div>

A la izquierda, tres caras cruzadas en el centro. A la derecha, un cubo correcto

Figura 29. A la izquierda, rotate-y-90 translate-z-16. A la derecha, translate-x-16 rotate-y-90.

translate-z-16 son 4 rem, la mitad del lado de size-32. -rotate-x-25 y rotate-y-35 son valores dinámicos, sin corchetes: inclinan el cubo para que se vean tres caras.

Otra opción válida es escribir el transform completo en una clase arbitraria, que respeta tu orden: [transform:rotateY(90deg)_translateZ(4rem)].

Para hacerlo girar, añade una animación al elemento con transform-3d:

@theme {
  --animate-girar-cubo: girar-cubo 8s linear infinite;

  @keyframes girar-cubo {
    from { transform: rotateX(-25deg) rotateY(0deg); }
    to { transform: rotateX(-25deg) rotateY(360deg); }
  }
}
<div class="relative size-32 animate-girar-cubo transform-3d motion-reduce:animate-none">...</div>

Cubo de colores girado en un instante de su animación

Figura 30. El cubo congelado en el segundo 1 de su giro continuo.

La animación incluye rotateX(-25deg) porque sustituye al valor de transform del elemento. Sin eso, perderías la inclinación.

3D en Tailwind 3.4

Toda la sección se puede escribir en v3 con propiedades arbitrarias. Esta es la tarjeta que gira, compilada con Tailwind 3.4.19:

<div class="group h-52 w-40 [perspective:300px]">
  <div class="relative h-full w-full transition-transform duration-700 ease-in-out
              [transform-style:preserve-3d] group-hover:[transform:rotateY(180deg)]">
    <div class="absolute inset-0 flex items-center justify-center rounded-xl bg-indigo-600 p-4 text-white
                [backface-visibility:hidden]">Ana</div>
    <div class="absolute inset-0 flex items-center justify-center rounded-xl bg-slate-800 p-4 text-white
                [backface-visibility:hidden] [transform:rotateY(180deg)]">Extensión 214</div>
  </div>
</div>

La tarjeta compilada con Tailwind 3.4 mostrando el dorso tras pasar el ratón

Figura 31. La versión 3.4 en hover. El resultado es el mismo.

Tailwind 4 Equivalente en 3.4
perspective-near [perspective:300px]
perspective-origin-top [perspective-origin:top]
transform-3d [transform-style:preserve-3d]
backface-hidden [backface-visibility:hidden]
rotate-y-180 [transform:rotateY(180deg)]
translate-z-16 [transform:translateZ(4rem)]

En v3 solo puedes tener una clase [transform:...] activa por elemento, de modo que los valores combinados se escriben en una sola: [transform:translateX(4rem)_rotateY(90deg)]. No las mezcles con rotate-45 o translate-x-4: en v3 también escriben en transform y se pisan.


14. Caso completo: catálogo con movimiento

Juntamos varias técnicas en una cabecera y tres tarjetas: la primera se comporta como la de la sección 10, la segunda está añadiendo un producto, y la tercera aún se está cargando.

<section class="bg-slate-100 p-6 dark:bg-slate-950">
  <header class="mb-4 flex items-center justify-between">
    <h2 class="text-xl font-bold text-slate-900 dark:text-white">Catálogo</h2>
    <a href="#" class="relative inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white
                       px-3 py-1.5 text-sm text-slate-700
                       dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200">
      Pedidos nuevos
      <span class="absolute -top-1 -right-1 flex size-3">
        <span class="absolute inline-flex size-full rounded-full bg-orange-400 opacity-75 motion-safe:animate-ping"></span>
        <span class="relative inline-flex size-3 rounded-full bg-orange-500"></span>
      </span>
    </a>
  </header>

  <div class="grid grid-cols-3 gap-4">
    <!-- Producto normal -->
    <article class="group overflow-hidden rounded-xl bg-white shadow-sm
                    transition-[translate,box-shadow] duration-300 ease-out
                    hover:-translate-y-1 hover:shadow-xl
                    motion-safe:animate-entrar motion-reduce:hover:translate-y-0 dark:bg-slate-900">
      <div class="overflow-hidden">
        <div class="flex h-28 items-center justify-center bg-linear-to-br from-indigo-500 to-sky-400
                    text-2xl font-bold text-white transition-transform duration-500 group-hover:scale-110
                    motion-reduce:transition-none motion-reduce:group-hover:scale-100">14"</div>
      </div>
      <div class="p-4">
        <h3 class="text-sm font-semibold text-slate-900 dark:text-white">Portátil UltraBook 14</h3>
        <p class="mt-1 font-bold text-indigo-700 dark:text-indigo-300">899 €</p>
        <button class="mt-3 w-full rounded-lg bg-indigo-600 py-2 text-sm font-medium text-white
                       transition duration-150 hover:bg-indigo-700 active:scale-95
                       motion-reduce:active:scale-100">Añadir</button>
      </div>
    </article>

    <!-- Producto añadiéndose al carrito: entra un poco después -->
    <article class="group overflow-hidden rounded-xl bg-white shadow-sm
                    transition-[translate,box-shadow] duration-300 ease-out
                    hover:-translate-y-1 hover:shadow-xl
                    motion-safe:animate-entrar motion-safe:[animation-delay:100ms]
                    motion-reduce:hover:translate-y-0 dark:bg-slate-900">
      <div class="overflow-hidden">
        <div class="flex h-28 items-center justify-center bg-linear-to-br from-pink-500 to-orange-400
                    text-2xl font-bold text-white transition-transform duration-500 group-hover:scale-110
                    motion-reduce:transition-none motion-reduce:group-hover:scale-100">16"</div>
      </div>
      <div class="p-4">
        <h3 class="text-sm font-semibold text-slate-900 dark:text-white">Portátil Gaming 16</h3>
        <p class="mt-1 font-bold text-indigo-700 dark:text-indigo-300">1499 €</p>
        <button disabled class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg
                                bg-indigo-400 py-2 text-sm font-medium text-white">
          <svg class="size-4 motion-safe:animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z"></path>
          </svg>
          Añadiendo
        </button>
      </div>
    </article>

    <!-- Esqueleto de carga -->
    <article class="overflow-hidden rounded-xl bg-white shadow-sm dark:bg-slate-900" aria-busy="true">
      <div class="motion-safe:animate-pulse">
        <div class="h-28 bg-slate-300 dark:bg-slate-700"></div>
        <div class="space-y-2 p-4">
          <div class="h-3.5 w-3/4 rounded bg-slate-300 dark:bg-slate-700"></div>
          <div class="h-5 w-1/3 rounded bg-slate-300 dark:bg-slate-700"></div>
          <div class="mt-3 h-9 rounded-lg bg-slate-300 dark:bg-slate-700"></div>
        </div>
      </div>
    </article>
  </div>
</section>

Catálogo con tres tarjetas: una normal, otra con spinner y una tercera en forma de esqueleto de carga

Figura 32. El catálogo con sus tres estados.

El mismo catálogo en modo oscuro

Figura 33. En modo oscuro, con las variantes dark: del módulo 12.

Un matiz: el spinner lleva motion-safe:animate-spin por simplicidad. Con movimiento reducido, el texto “Añadiendo” sigue informando de lo que pasa. Si el icono fuera lo único que indicara la carga, habría que mantener la animación, porque lo que hace es informar.

Fíjate en una interacción con animate-entrar: esta animación escribe transform, y gracias a las propiedades individuales no choca con hover:-translate-y-1, que escribe translate. Además, el relleno both deja transform: none al terminar, lo que no interfiere con nada. En v3 el hover también iría en transform, y el relleno de la animación lo anularía: tendrías que usar el modo backwards en la animación para no retener su último fotograma.


15. Ejercicios

Ejercicio 1: Un botón bien hecho

Crea un botón “Comprar” que cambie de color en hover y se hunda al pulsarlo. Las dos cosas deben estar animadas, debe tener un contorno visible con el teclado, y con movimiento reducido no debe moverse.

Ver solución
<button class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white
               transition duration-150 hover:bg-indigo-700
               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600
               active:scale-95
               motion-reduce:transition-none motion-reduce:active:scale-100">
  Comprar
</button>

transition incluye tanto el color como scale. Con transition-colors, la escala saltaría, y es justo el descuido que tenía el botón de la sección 10. motion-reduce:active:scale-100 anula el movimiento; sin él, la escala cambiaría igualmente, solo que de golpe.

Ejercicio 2: ¿Por qué no se anima?

Al migrar a v4, esta tarjeta deja de animarse al entrar el ratón, aunque el aumento se sigue aplicando. Explica la causa y corrígela. Después, responde: ¿por qué no se ve ningún error en la consola?

<div class="transition-[transform,box-shadow] duration-300 hover:scale-105 hover:shadow-lg">...</div>
Ver solución

La sombra se sigue animando, pero scale-105 no. En v4 esa clase escribe en la propiedad scale, y no está en la lista de transition-[...]. Hay dos arreglos correctos:

<div class="transition-[scale,box-shadow] duration-300 hover:scale-105 hover:shadow-lg">...</div>
<div class="transition duration-300 hover:scale-105 hover:shadow-lg">...</div>

No hay error porque no hay nada incorrecto. transition-property: transform es CSS válido, y scale también. Sencillamente, ninguna transición vigila esa propiedad. Se detecta con el panel Animations o con el.getAnimations(), que al pasar el ratón no muestra scale.

Ejercicio 3: Subrayado que crece

Haz un enlace cuyo subrayado crezca desde la izquierda al pasar el ratón, usando un pseudoelemento.

Ver solución
<a href="#" class="relative inline-block text-indigo-700
                   after:absolute after:inset-x-0 after:-bottom-0.5 after:h-0.5
                   after:origin-left after:scale-x-0 after:bg-indigo-600
                   after:transition-transform after:duration-300
                   hover:after:scale-x-100">
  Ver todos los productos
</a>

Tres fotogramas del enlace: el subrayado va creciendo de izquierda a derecha

Figura 34. El subrayado crece desde origin-left, a los 0, 150 y 300 ms.

Se anima scale-x, y no width, porque no provoca layout. Si cambias a origin-center crecerá desde el centro. Recuerda que el orden correcto es hover:after:, y no after:hover: (módulo 11). En las dos versiones funciona igual, porque en 3.4 scale-x-* va por transform, y transition-transform lo cubre.

Ejercicio 4: Aviso de error

Crea una clase animate-agitar y aplícala a un aviso de error, tanto en v4 como en v3. Añade la protección para el movimiento reducido.

Ver solución

En Tailwind 4:

@theme {
  --animate-agitar: agitar 0.4s ease-in-out;

  @keyframes agitar {
    0%, 100% { transform: translateX(0); }
    20%, 60% { transform: translateX(-0.5rem); }
    40%, 80% { transform: translateX(0.5rem); }
  }
}

En Tailwind 3.4:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      animation: { agitar: 'agitar 0.4s ease-in-out' },
      keyframes: {
        agitar: {
          '0%, 100%': { transform: 'translateX(0)' },
          '20%, 60%': { transform: 'translateX(-0.5rem)' },
          '40%, 80%': { transform: 'translateX(0.5rem)' },
        },
      },
    },
  },
};

El HTML vale para las dos versiones:

<div class="motion-safe:animate-agitar rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800">
  La contraseña no es correcta
</div>

El mensaje sigue viéndose con movimiento reducido. El color y el texto son los que informan del error, y el movimiento es solo un refuerzo.

Ejercicio 5: Esqueleto de carga

Construye el esqueleto de una tarjeta de producto, con su imagen y dos líneas. Debe anunciar la carga a los lectores de pantalla y no latir con movimiento reducido.

Ver solución
<div class="w-56 rounded-xl bg-white p-4 shadow-sm" role="status" aria-label="Cargando producto">
  <div class="space-y-3 motion-safe:animate-pulse" aria-hidden="true">
    <div class="h-24 rounded-lg bg-slate-200"></div>
    <div class="h-3.5 w-3/4 rounded bg-slate-200"></div>
    <div class="h-3.5 w-1/2 rounded bg-slate-200"></div>
  </div>
</div>

role="status" con aria-label anuncia la carga una sola vez, y aria-hidden oculta las barras. Conviene que el esqueleto tenga el mismo tamaño que la tarjeta real, porque si no, al cargar se movería toda la página.

Ejercicio 6: Carrito lateral sin JavaScript

Crea un panel lateral con popover que entre desde la derecha y salga también animado, con un fondo que se oscurezca. No uses JavaScript.

Ver solución
<button popovertarget="cajon" class="rounded-lg bg-indigo-600 px-3 py-2 text-white">Ver carrito</button>

<aside id="cajon" popover
       class="ms-auto h-dvh max-h-none w-72 translate-x-full bg-white p-6 shadow-2xl
              transition-all transition-discrete duration-300 ease-out
              open:translate-x-0 starting:open:translate-x-full
              backdrop:bg-black/0 backdrop:transition-all backdrop:transition-discrete backdrop:duration-300
              open:backdrop:bg-black/40 starting:open:backdrop:bg-black/0
              motion-reduce:transition-none">
  <h2 class="text-lg font-bold">Tu carrito</h2>
  <p class="mt-2 text-sm text-slate-600">Portátil UltraBook 14: 899 €</p>
</aside>

Panel blanco abierto a la derecha, con el resto de la página oscurecido

Figura 35. El panel abierto y el fondo oscurecido.

Los tres juegos de clases son los de la figura 15: translate-x-full para el estado cerrado, open:translate-x-0 para el abierto, y starting:open:translate-x-full para el punto de partida. ms-auto lo pega a la derecha, y max-h-none anula el límite que el navegador pone a los popover. Medido: al abrirlo hay 2 transiciones (el panel y el fondo) y termina en el borde derecho, a toda la altura de la ventana. Al cerrarlo, display sigue en block hasta que acaba.

Ejercicio 7: Tarjeta con capas

Haz una tarjeta que se incline hacia el puntero en hover, y en la que el título, el texto y el precio floten a distintas alturas.

Ver solución
<div class="group w-64 p-6 perspective-near">
  <article class="relative h-40 rounded-xl bg-linear-to-br from-indigo-600 to-sky-500 p-4 text-white shadow-lg
                  transition-transform duration-500 ease-out transform-3d
                  group-hover:rotate-x-12 group-hover:-rotate-y-12
                  motion-reduce:transition-none motion-reduce:group-hover:rotate-x-0 motion-reduce:group-hover:rotate-y-0">
    <h3 class="font-bold translate-z-10">Monitor 27</h3>
    <p class="mt-1 text-sm translate-z-6">Pantalla QHD de 165 Hz</p>
    <span class="absolute right-4 bottom-4 rounded-full bg-white px-3 py-1 text-sm font-bold text-indigo-700 translate-z-16">279 €</span>
  </article>
</div>

La tarjeta en reposo y, a su lado, inclinada con el precio flotando sobre ella

Figura 36. En reposo y con hover. El precio, con más translate-z, parece flotar más cerca.

La clave es transform-3d en la tarjeta: sin esa clase, translate-z-* no tendría efecto visible. Por la misma razón, overflow-hidden no debe ir en la tarjeta, porque la aplanaría. El hover se detecta en la escena, que no gira.

Ejercicio 8 (propuesto): Cubo interactivo

Parte del cubo de la sección 13. Haz que gire solo cuando se pase el ratón por encima, con animation-play-state ([animation-play-state:paused] en reposo y hover:[animation-play-state:running]). Pon el nombre de un producto de la tienda en cada cara, y comprueba qué cambia al añadir backface-hidden a las caras: ¿sigues viendo las traseras a través de las delanteras?

Ejercicio 9 (propuesto): Tarjeta que gira en v3

Lleva la tarjeta de Ana García a un proyecto con Tailwind 3.4, partiendo de la tabla de equivalencias de la sección 13. Añade también el giro con el foco del teclado, y comprueba con el panel Animations que la duración es la esperada.

Ejercicio 10 (propuesto): Preguntas frecuentes

Construye una sección de preguntas frecuentes de la tienda, con cuatro paneles del tipo del acordeón de la sección 9. Cada botón debe alternar un atributo data-abierto, actualizar aria-expanded y girar un icono de flecha con transition-transform. Con movimiento reducido, los paneles se abren sin animación.


Resumen

Concepto API Idea clave
Transición transition, transition-colors, transition-[...] Se coloca en el estado normal. Dos transition-* no se suman
Tiempo duration-*, delay-* Solo para transiciones. Números libres en v4; en 3.4, duration-[1234ms]
Curva ease-linear, ease-in, ease-out, ease-in-out, --ease-* ease-out para entrar, ease-in para salir
Coste translate, rotate, scale, opacity Animarlas no provoca layout
Transformaciones translate-*, rotate-*, scale-*, skew-*, origin-* No afectan al diseño. No funcionan en elementos en línea
Propiedades individuales 🔄 v4.0 translate, rotate, scale transition-[transform] y transform-none no las alcanzan
Orden translate, rotate, scale, transform Es fijo. Para otro orden, [transform:...]
Animaciones animate-spin, -ping, -pulse, -bounce [animation-delay:...] para ajustarlas
Animaciones propias 🔄 v4.0 --animate-* y @keyframes en @theme En 3.4, theme.extend.animation y keyframes
Primera aparición 🆕 v4.0 starting: Punto de partida de una transición. En 3.4, [@starting-style]:
Propiedades discretas 🆕 v4.0 transition-discrete Permite animar el cierre de display. En 3.4, [transition-behavior:allow-discrete]
Altura automática grid-rows-[0fr], grid-rows-[1fr] auto no se interpola y fr sí
Movimiento reducido motion-safe:, motion-reduce: Elimina el desplazamiento y mantén lo informativo
Rendimiento will-change-transform, transform-gpu Solo ante un problema medido
Ejes 3D 🆕 v4.0 rotate-x-*, rotate-y-*, translate-z-*, scale-z-* Y crece hacia abajo, Z hacia ti
Perspectiva 🆕 v4.0 perspective-*, perspective-origin-* Va en el padre. Es la distancia del ojo
Conservar el 3D 🆕 v4.0 transform-3d, transform-flat overflow-hidden y opacity-* aplanan
Dos caras 🆕 v4.0 backface-hidden, backface-visible Escena fija, tarjeta que gira y dos caras
Depuración el.getAnimations(), panel Animations Si no aparece ahí, la transición no existe