Animaciones
Transiciones, keyframes y motion — interacciones de UI fluidas con Tailwind CSS v4.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
// Transiciones
<button className="transition-colors duration-200 ease-in-out hover:bg-blue-600">
<div className="transition-all duration-300 hover:scale-105 hover:shadow-lg">
<div className="transition-opacity duration-150 opacity-0 data-[visible]:opacity-100">
// Animaciones integradas
<div className="animate-spin"> {/* rotación infinita */}
<div className="animate-ping"> {/* pulso estilo radar */}
<div className="animate-pulse"> {/* pulso suave de opacidad */}
<div className="animate-bounce"> {/* rebote */}
// Animación personalizada con keyframes
<div className="animate-fade-in"> {/* definida en @theme */}
// Movimiento reducido
<div className="motion-safe:animate-bounce motion-reduce:animate-none">/* globals.css — define animaciones personalizadas */
@import "tailwindcss";
@theme {
--animate-fade-in: fade-in 0.3s ease-out;
--animate-slide-up: slide-up 0.4s ease-out;
--animate-scale-in: scale-in 0.2s ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}Cuándo usarlo: Cuando las interacciones de UI se sienten abruptas — agrega transiciones para estados hover, animaciones para elementos que entran y indicadores de carga.
Ejemplo funcional
"use client";
import { useState } from "react";
export function AnimatedNotificationList() {
const [notifications, setNotifications] = useState<
{ id: number; text: string; type: "info" | "success" | "error" }[]
>([]);
let nextId = 0;
function addNotification(type: "info" | "success" | "error") {
const id = ++nextId;
setNotifications((prev) => [
...prev,
{ id, text: `Notificación ${type} #${id}`, type },
]);
setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, 3000);
}
const colors = {
info: "bg-blue-50 border-blue-200 text-blue-800",
success: "bg-green-50 border-green-200 text-green-800",
error: "bg-red-50 border-red-200 text-red-800",
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<button
onClick={() => addNotification("info")}
className="rounded bg-blue-500 px-3 py-1.5 text-sm text-white transition-all duration-200 hover:bg-blue-600 active:scale-95"
>
Info
</button>
<button
onClick={() => addNotification("success")}
className="rounded bg-green-500 px-3 py-1.5 text-sm text-white transition-all duration-200 hover:bg-green-600 active:scale-95"
>
Éxito
</button>
<button
onClick={() => addNotification("error")}
className="rounded bg-red-500 px-3 py-1.5 text-sm text-white transition-all duration-200 hover:bg-red-600 active:scale-95"
>
Error
</button>
</div>
<div className="fixed top-4 right-4 z-50 flex w-80 flex-col gap-2">
{notifications.map((n) => (
<div
key={n.id}
className={`animate-slide-in-right rounded-lg border p-3 shadow-lg ${colors[n.type]}`}
>
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{n.text}</p>
<button
onClick={() => setNotifications((prev) => prev.filter((x) => x.id !== n.id))}
className="ml-2 text-current opacity-50 transition-opacity hover:opacity-100"
>
X
</button>
</div>
{/* Barra de progreso animada */}
<div className="mt-2 h-0.5 animate-shrink rounded bg-current opacity-30" />
</div>
))}
</div>
</div>
);
}/* Agrégalos a globals.css */
@theme {
--animate-slide-in-right: slide-in-right 0.3s ease-out;
--animate-shrink: shrink 3s linear forwards;
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}Lo que demuestra esto:
- Animación personalizada de deslizamiento para notificaciones
- Barra de progreso con
animation-fill-mode: forwards active:scale-95para feedback al presionar el botón- Utilidades de transición para efectos hover
- Cierre automático con temporizador
Profundización
Cómo funciona
- Las utilidades
transition-*controlan qué propiedades CSS transicionan, la duración y la curva de aceleración - Las utilidades
animate-*aplican la abreviaturaanimation— nombre, duración, timing, iteración - v4 define animaciones personalizadas con
--animate-*en@theme, emparejadas con@keyframes motion-safe:ymotion-reduce:respetan la media queryprefers-reduced-motion- Las transiciones se activan en cambios de estado (hover, focus, cambio de clase); las animaciones se ejecutan al montar o cuando se disparan
Variaciones
Grupos de propiedades de transición:
// Propiedades específicas (mejor rendimiento)
<div className="transition-transform duration-300"> {/* solo transform */}
<div className="transition-colors duration-200"> {/* solo propiedades de color */}
<div className="transition-opacity duration-150"> {/* solo opacity */}
<div className="transition-shadow duration-200"> {/* solo box-shadow */}
<div className="transition-[transform,opacity] duration-300"> {/* conjunto personalizado */}Animaciones de entrada escalonadas:
{items.map((item, i) => (
<div
key={item.id}
className="animate-fade-in opacity-0"
style={{ animationDelay: `${i * 100}ms`, animationFillMode: "forwards" }}
>
{item.name}
</div>
))}Acordeón solo con CSS:
<details className="group rounded border">
<summary className="cursor-pointer p-4 font-medium">
Haz clic para expandir
<span className="ml-2 inline-block transition-transform group-open:rotate-90">
▶
</span>
</summary>
<div className="overflow-hidden transition-all duration-300 group-open:max-h-96 max-h-0">
<div className="p-4 pt-0">Contenido oculto aquí</div>
</div>
</details>Skeleton de carga:
function Skeleton({ className }: { className?: string }) {
return (
<div className={`animate-pulse rounded bg-gray-200 dark:bg-gray-700 ${className}`} />
);
}
// Uso
<div className="space-y-3">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>Notas de TypeScript
// Retraso de animación como prop
function AnimatedItem({
delay,
children,
}: {
delay: number;
children: React.ReactNode;
}) {
return (
<div
className="animate-fade-in opacity-0 [animation-fill-mode:forwards]"
style={{ animationDelay: `${delay}ms` }}
>
{children}
</div>
);
}
// Tipado de eventos de transición
function handleTransitionEnd(e: React.TransitionEvent<HTMLDivElement>) {
if (e.propertyName === "opacity") {
// Transición de opacidad completada
}
}Errores comunes
-
transition-allincluye propiedades de layout — Transicionarwidth,heightopaddingprovoca recálculo de layout y jank. Solución: Usatransition-transformytransition-opacitypara animaciones fluidas a 60fps. -
Animación al montar — Las animaciones CSS se ejecutan en el renderizado inicial, lo que puede causar un flash. Solución: Empieza con
opacity-0y usaanimation-fill-mode: forwardspara terminar en opacidad completa. -
prefers-reduced-motiones importante — Los usuarios con trastornos vestibulares pueden verse afectados por el movimiento. Solución: Envuelve las animaciones enmotion-safe:o proporcionamotion-reduce:animate-none. -
animate-*se reproduce en cada cambio de key de re-renderizado — Cambiar lakeyde React desmonta el elemento y reproduce la animación. Esto puede ser intencional o un bug. -
Capas GPU — Usa
will-change-transformcon moderación. Demasiadas capas GPU desperdician memoria. Solución: Agregawill-changesolo a elementos que se están animando activamente.
Alternativas
| Alternativa | Cuándo usarla | Cuándo no usarla |
|---|---|---|
| Framer Motion | Necesitas animaciones basadas en gestos, de layout y de salida | Solo necesitas transiciones hover simples |
CSS @starting-style | Necesitas animaciones de entrada nativas en CSS (sin JS) | Necesitas amplio soporte de navegadores hoy |
| GSAP | Necesitas animaciones complejas basadas en líneas de tiempo | Las transiciones CSS simples son suficientes |
| React Spring | Quieres animación basada en física en React | Prefieres soluciones nativas de CSS |
| View Transitions API | Quieres transiciones animadas de página a página | Necesitas animaciones a nivel de elemento |
FAQs
¿Cuál es la diferencia entre las utilidades transition-* y animate-*?
transition-*— se activa en cambios de estado (hover, focus, cambio de clase); controla qué propiedades transicionan, la duración y la curva de aceleraciónanimate-*— aplica una animación@keyframesque se ejecuta al montar o cuando se dispara
¿Cómo defines una animación personalizada en Tailwind v4?
@theme {
--animate-fade-in: fade-in 0.3s ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}Luego usa className="animate-fade-in".
¿Por qué deberías preferir transition-transform sobre transition-all?
transition-all incluye propiedades de layout como width y height, que provocan recálculo de layout y jank. transition-transform y transition-opacity se ejecutan en la GPU a 60fps.
¿Cómo creas animaciones de entrada escalonadas para una lista de elementos?
{items.map((item, i) => (
<div
key={item.id}
className="animate-fade-in opacity-0"
style={{ animationDelay: `${i * 100}ms`, animationFillMode: "forwards" }}
>
{item.name}
</div>
))}¿Cómo respetas la preferencia de movimiento reducido del usuario?
Envuelve las animaciones en motion-safe: o proporciona un fallback con motion-reduce:animate-none. Estos se mapean a la media query prefers-reduced-motion.
¿Cuáles son las cuatro utilidades de animación integradas?
animate-spin— rotación infinitaanimate-ping— pulso estilo radaranimate-pulse— pulso suave de opacidadanimate-bounce— efecto de rebote
¿Cómo construyes un componente skeleton de carga?
function Skeleton({ className }: { className?: string }) {
return (
<div className={`animate-pulse rounded bg-gray-200 dark:bg-gray-700 ${className}`} />
);
}Trampa: Un elemento empieza visible, parpadea y luego anima su entrada. ¿Qué salió mal?
Las animaciones CSS se ejecutan desde el estado actual del elemento. Si el elemento empieza con opacity: 1, el primer frame es visible. Solución: empieza con opacity-0 y usa animation-fill-mode: forwards (o [animation-fill-mode:forwards]).
Trampa: Usar will-change-transform en muchos elementos. ¿Es seguro?
No. Cada elemento con will-change crea una capa GPU. Demasiadas capas desperdician memoria. Agrega will-change solo a elementos que se están animando activamente y elimínalo después.
¿Cómo tipas un manejador React TransitionEvent en TypeScript?
function handleTransitionEnd(e: React.TransitionEvent<HTMLDivElement>) {
if (e.propertyName === "opacity") {
// transición de opacidad completada
}
}¿Cómo tipas un componente AnimatedItem que acepta un prop delay?
function AnimatedItem({
delay,
children,
}: {
delay: number;
children: React.ReactNode;
}) {
return (
<div
className="animate-fade-in opacity-0 [animation-fill-mode:forwards]"
style={{ animationDelay: `${delay}ms` }}
>
{children}
</div>
);
}Relacionado
- Utilidades — clases de utilidad principales
- Utilidades personalizadas — definición de utilidades de animación
- Configuración — configuración de
@themepara animaciones - shadcn Dialog — transiciones animadas de diálogo