Patrones de Animación
Patrones para copiar y pegar las necesidades de animación UI más comunes.
Cada patrón está construido sobre los tokens y springs de motion-foundations.
No definas nuevos valores de duración o easing aquí; impórtalos.
Cuándo Activar
- Animando un botón, tarjeta, modal o notificación toast
- Construyendo entradas de lista con efecto escalonado (stagger)
- Configurando transiciones de página en Next.js App Router
- Añadiendo animaciones de entrada o salida a contenido condicional
- Implementando revelación al hacer scroll, progreso vinculado al scroll, o secciones adhesivas
- Construyendo tarjetas expandibles, acordeones o transiciones de elementos compartidos
Salidas
Este skill produce:
- Animación accesible y segura para SSR para todos los componentes UI estándar
- Renderizados condicionales envueltos en
AnimatePresencecon comportamiento de salida correcto - Componente envolvente de transición de página para Next.js App Router
- Patrones de revelación al scroll y vinculados al scroll usando
useScroll+useTransform - Patrones de animación de layout (
layout,layoutId) para elementos que se expanden y se desvanecen
Principios
- Cada patrón importa de
motion-foundations. Sin números en crudo. - Cada render condicional está envuelto en
AnimatePresencecon unakey. - Las animaciones de salida siempre se definen junto con las de entrada, nunca como una ocurrencia tardía.
layoutse usa solo para desplazamientos pequeños y aislados. Los subárboles grandes usan transformaciones explícitas.
Reglas
- Siempre envuelve los renderizados condicionales en
AnimatePresencecon unakeyen el hijo directo. Sin una key, las animaciones de salida nunca se disparan. - Siempre define
exitcuando definasinitial+animate. Una animación sin salida está incompleta. - Usa
mode="wait"en las transiciones de página. La entrada no debe comenzar hasta que la salida termine. - Nunca uses
layouten subárboles con más de ~5 hijos o DOM profundamente anidado. Usa transformaciones explícitasx/y. - El intervalo de stagger debe mantenerse entre
0.05sy0.10s. Por debajo se siente mecánico; por encima se siente lento. - Los modales siempre deben incluir: trampa de foco, cierre con tecla Escape, bloqueo de scroll,
role="dialog",aria-modal="true". - Las revelaciones al scroll usan
viewport={{ once: true }}. Repetir al salir del scroll distrae, no informa. - Todos los valores de token se importan de
motion-foundations. Sin números en línea.
Guía de Decisión
Elegir el patrón correcto
| Situación | Patrón |
|---|---|
| Elemento aparece / desaparece | AnimatePresence |
| Lista de elementos cargando en secuencia | Variantes con stagger |
| Navegando entre rutas | Envolvente de transición de página |
| Elemento cambia de tamaño en su lugar | Prop layout |
| Mismo elemento se mueve entre contextos de página | layoutId |
| Elemento entra al hacerse scroll hacia él | whileInView |
| Valor vinculado a la posición de scroll | useScroll + useTransform |
Cuándo usar mode="wait" vs mode="sync"
| Modo | Úsalo cuando |
|---|---|
wait | Transiciones de página, intercambios de contenido (uno a la vez) |
sync | Notificaciones apiladas, elementos de lista (la superposición está bien) |
popLayout | Elementos eliminados de una lista con reflujo |
Conceptos Clave
Contrato de AnimatePresence
Tres cosas deben ser siempre ciertas:
AnimatePresenceenvuelve el condicional- El hijo directo tiene una
key - El hijo tiene una prop
exit
Si falta alguna de estas, la animación de salida falla silenciosamente.
layout vs layoutId
layout— anima el cambio de tamaño/posición del propio elemento en su lugarlayoutId— vincula dos elementos separados, haciendo una transición de fundido entre ellos a través de renderizados
Usa layout="position" en el texto dentro de un contenedor que se expande para evitar que el reflujo del texto se anime.
Ejemplos de Código
Retroalimentación de botón
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.button
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
transition={springs.snappy}
/>
Lista escalonada
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // dentro de la regla 0.05–0.10
delayChildren: 0.1,
},
},
}
const item = {
hidden: { opacity: 0, y: motionTokens.distance.md },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}
<motion.ul variants={container} initial="hidden" animate="visible">
{items.map((i) => (
<motion.li key={i.id} variants={item} />
))}
</motion.ul>
Modal
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// Envuelve en el punto de llamada:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* Superposición */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel — requisitos de accesibilidad: trampa de foco, cierre con Escape,
bloqueo de scroll, role="dialog", aria-modal="true" */}
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
initial={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
transition={springs.gentle}
/>
</>
)
}
Pila de notificaciones toast
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<AnimatePresence mode="sync">
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
transition={springs.snappy}
/>
))}
</AnimatePresence>
Transición de página (Next.js App Router)
// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"
const variants = {
initial: { opacity: 0, y: motionTokens.distance.sm },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -motionTokens.distance.sm },
}
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
variants={variants}
initial="initial"
animate="enter"
exit="exit"
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}
Revelación al scroll
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.lg }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }} // once: true — regla 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
Barra de progreso de scroll
"use client"
import { motion, useScroll } from "motion/react"
export function ScrollProgress() {
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
style={{ scaleX: scrollYProgress }}
/>
)
}
Tarjeta expandible
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
export function ExpandingCard({ title, body }: { title: string; body: string }) {
const [expanded, setExpanded] = useState(false)
return (
<motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
{/* layout="position" evita que el reflujo del texto se anime */}
<motion.h2 layout="position" className="font-semibold">
{title}
</motion.h2>
<AnimatePresence>
{expanded && (
<motion.p
key="body"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{body}
</motion.p>
)}
</AnimatePresence>
</motion.div>
)
}
Fundido de elemento compartido
// Contexto de origen
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// Contexto de destino (mismo layoutId — motion maneja la transición)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
Acordeón
<motion.div
initial={false}
animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
style={{ transformOrigin: "top", overflow: "hidden" }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
> {children}
</motion.div>
Ejemplo Completo
Una lista escalonada que entra al montarse, maneja presencia condicional y
respeta el movimiento reducido — combinando tokens, springs, AnimatePresence y
el hook de accesibilidad de motion-foundations:
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
const containerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
}
function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
const safe = useSafeMotion(motionTokens.distance.sm)
return (
<motion.li
variants={{
hidden: safe.initial,
visible: safe.animate,
}}
exit={safe.exit}
transition={springs.gentle}
className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
>
<span>{label}</span>
<button onClick={onRemove}>Eliminar</button>
</motion.li>
)
}
export function AnimatedList({ items, onRemove }: {
items: { id: string; label: string }[]
onRemove: (id: string) => void
}) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-2"
>
<AnimatePresence mode="popLayout">
{items.map((item) => (
<ListItem
key={item.id}
label={item.label}
onRemove={() => onRemove(item.id)}
/>
))}
</AnimatePresence>
</motion.ul>
)
}
Restricciones / No Objetivos
Este skill no cubre:
- Definiciones de tokens y springs → consulta
motion-foundations - Interacciones de arrastre, gestos de deslizamiento, listas reordenables → consulta
motion-advanced - Animaciones de texto (revelación de palabras/caracteres, contadores) → consulta
motion-advanced - Dibujo de trazados SVG o morphing → consulta
motion-advanced - Hooks de animación personalizados → consulta
motion-advanced - Transiciones solo CSS que no usan
motion/react
Antipatrones
| Antipatrón | Regla violada | Solución |
|---|---|---|
Hijo de AnimatePresence sin key | Regla 1 | Añade una key estable al hijo directo |
initial + animate sin exit | Regla 2 | Siempre define los tres juntos |
Transición de página sin mode="wait" | Regla 3 | Añade mode="wait" a AnimatePresence |
layout en una lista de 50 elementos | Regla 4 | Usa mode="popLayout" o transformaciones explícitas |
staggerChildren: 0.2 en una lista de 10 elementos | Regla 5 | Limítalo a 0.08–0.10 |
| Modal sin trampa de foco | Regla 6 | Añade focus-trap-react o Radix Dialog |
whileInView sin viewport={{ once: true }} | Regla 7 | Las entradas repetidas distraen, no informan |
transition={{ duration: 0.3 }} en línea | Regla 8 | Usa motionTokens.duration.normal |
Skills Relacionados
motion-foundations— define todos los tokens, springs, el hookuseSafeMotiony las protecciones SSR que cada patrón aquí importa. Debe configurarse primero.motion-advanced— extiende estos patrones con arrastre, gestos, SVG, texto, hooks personalizados y secuenciación imperativa. No redefine ningún patrón de este skill.