Web · React/Next.js · motion/react

Patrones de Animación UI para React

NutriTrack SaaS — Referencia de componentes animados listos para producción

8 patrones
motion/react v11
SSR-safe
Accesible
1

Button Feedback

Escala en hover/tap usando tokens de motion — sin números inline

AnimatedSaveButton.tsx token: scale.pop spring: snappy SSR-safe
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"

// NutriTrack: botón "Guardar plan de comidas"
export function SavePlanButton({ onClick, loading }: SavePlanButtonProps) {
  return (
    <motion.button
      onClick={onClick}
      disabled={loading}
      whileHover={{ scale: motionTokens.scale.pop }}
      whileTap={{ scale: motionTokens.scale.press }}
      transition={springs.snappy}
      className="btn-primary"
    >
      {loading ? "Guardando…" : "Guardar plan"}
    </motion.button>
  )
}
Preview interactivo
2

Stagger List

Lista de pacientes que aparece en cascada al montar — staggerChildren 0.08s

PatientList.tsx AnimatePresence stagger 0.08s SSR-safe
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

const container = {
  hidden: {},
  visible: {
    transition: {
      staggerChildren: 0.08,  // Regla 5: entre 0.05–0.10
      delayChildren: 0.1,
    },
  },
}

const item = {
  hidden:  { opacity: 0, y: motionTokens.distance.md },
  visible: { opacity: 1, y: 0, transition: springs.gentle },
}

export function PatientList({ patients, onRemove }) {
  return (
    <motion.ul variants={container} initial="hidden" animate="visible">
      <AnimatePresence mode="popLayout">
        {patients.map((p) => (
          <motion.li key={p.id} variants={item} exit={safe.exit}>
            <PatientCard {...p} onRemove={() => onRemove(p.id)} />
          </motion.li>
        ))}
      </AnimatePresence>
    </motion.ul>
  )
}
Lista de pacientes NutriTrack
  • AM
    Ana Martínez
    Plan pérdida de peso · Sesión hoy
    Activo
  • CL
    Carlos López
    Plan masa muscular · Sesión mañana
    Premium
  • SG
    Sofia García
    Diabetes tipo 2 · Seguimiento semanal
    Alerta
  • MP
    Miguel Pérez
    Rendimiento deportivo · Nuevo
    Nuevo
3

Toast Stack

Notificaciones apiladas con entrada desde la derecha — mode="sync" para solapamiento

ToastProvider.tsx mode="sync" spring: snappy
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

// mode="sync" → toasts se solapan durante la transición (correcto)
<AnimatePresence mode="sync">
  {toasts.map((t) => (
    <motion.div
      key={t.id}  // Regla 1: key siempre
      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}  // Regla 8: usa tokens, no números
    />
  ))}
</AnimatePresence>
Notificaciones NutriTrack
Plan guardado
Plan semanal de Ana Martínez actualizado correctamente
📊
Informe disponible
El informe mensual de macros está listo
⚠️
Objetivo no alcanzado
Carlos López: déficit calórico del 23% esta semana
4

Modal con AnimatePresence

Confirmación de borrado — overlay + panel, exit siempre definido, accesibilidad completa

DeletePlanModal.tsx exit definido role="dialog" aria-modal
// Reglas 1, 2, 6: AnimatePresence + key + exit + accesibilidad
<AnimatePresence>
  {isOpen && <DeletePlanModal key="delete-modal" />}
</AnimatePresence>

export function DeletePlanModal({ onClose, onConfirm }) {
  // focus trap + Escape → onClose (Regla 6)
  return (
    <>
      <motion.div className="fixed inset-0 bg-black/50"
        initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
        onClick={onClose} />
      <motion.div
        role="dialog" aria-modal="true"
        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}
      />
    </>
  )
}
Modal de confirmación NutriTrack
5

Expanding Card (layout + AnimatePresence)

Card de macros del día expandible — layout en el contenedor, AnimatePresence en el body

MacrosDayCard.tsx layout="position" AnimatePresence
export function MacrosDayCard({ title, macros }) {
  const [expanded, setExpanded] = useState(false)
  return (
    <motion.div layout onClick={() => setExpanded(!expanded)}>
      {"/* layout='position' evita que el texto refluya animado */"}
      <motion.h2 layout="position">{title}</motion.h2>

      <AnimatePresence>
        {expanded && (
          <motion.div
            key="macros-body"  // Regla 1
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}  // Regla 2
            transition={{ duration: motionTokens.duration.fast }}
          >
            <MacrosBreakdown data={macros} />
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  )
}
Cards de macros — clic para expandir
🥗 Macros del día — Ana Martínez
Proteínas
148g
Carbos
210g
Grasas
54g
💪 Macros del día — Carlos López
Proteínas
188g
Carbos
320g
Grasas
42g
6

Page Transition (Next.js App Router)

Wrapper de transición entre rutas — mode="wait" para que exit termine antes de enter

PageTransition.tsx mode="wait" usePathname 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 }) {
  const pathname = usePathname()
  return (
    // mode="wait": exit termina ANTES de que entre la nueva página (Regla 3)
    <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>
  )
}
Simulación de transición entre rutas
Dashboard
Pacientes
Planes
Informes
📍 /dashboard — Entrada con mode="wait"
Bienvenido, Dr. Álvarez
12 pacientes activos · 3 sesiones hoy · 1 alerta pendiente
7

Scroll Reveal

Elementos que aparecen al entrar en viewport — viewport={{ once: true }} obligatorio

WeeklyStats.tsx once: true whileInView
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

// Tarjetas de estadísticas semanales — se revelan al hacer scroll
{weeklyStats.map((stat, i) => (
  <motion.div
    key={stat.id}
    initial={{ opacity: 0, y: motionTokens.distance.lg }}
    whileInView={{ opacity: 1, y: 0 }}
    viewport={{ once: true, margin: "-80px" }}  // Regla 7
    transition={{
      duration: motionTokens.duration.slow,
      ease: motionTokens.easing.smooth,
      delay: i * 0.08,
    }}
  />
))}
Estadísticas semanales — revelan al scroll
🎯
Objetivos cumplidos
8 de 12 pacientes cumplieron su objetivo semanal
67%
📈
Adherencia media
Cumplimiento del plan nutricional esta semana
84%
Kcal media diaria
Promedio de todos los planes activos
1.840
8

Scroll Progress Bar

Barra de progreso de lectura ligada a scrollYProgress — useScroll + useTransform

ScrollProgress.tsx useScroll scaleX SSR-safe
"use client"
import { motion, useScroll } from "motion/react"

// Barra de progreso en el informe semanal de NutriTrack
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 }}
    />
  )
}

// Uso: importar en el layout del informe semanal
// <ScrollProgress />  — se coloca fuera del contenido principal
Informe semanal con barra de progreso (68% leído)
📋 Informe semanal — Semana 24/2025
Resumen ejecutivo del rendimiento de todos los pacientes... • Adherencia media: 84% ↑3% vs semana anterior • Variaciones de peso acumuladas registradas • 3 pacientes con alerta de déficit calórico
📐

8 Reglas de Oro

Violaciones silenciosas que rompen las animaciones en producción

Regla 1
AnimatePresence + key obligatorio
Envuelve renders condicionales en AnimatePresence. El hijo directo siempre necesita key. Sin key, el exit nunca se dispara.
<AnimatePresence><div key="modal" />
Regla 2
exit siempre junto a initial+animate
Una animación sin exit es incompleta. Define los tres siempre juntos: initial, animate, exit.
initial animate exit
Regla 3
Page transitions: mode="wait"
En transiciones de página, mode="wait" garantiza que el exit termina antes de que empiece el enter. Evita contenido superpuesto.
mode="wait"
Regla 4
layout en subárboles pequeños
No uses layout en listas de más de ~5 hijos o DOM muy anidado. Usa mode="popLayout" o transforms explícitos.
mode="popLayout"
Regla 5
stagger: 0.05–0.10s
El intervalo de stagger debe estar entre 0.05s y 0.10s. Por debajo parece mecánico; por encima resulta lento.
staggerChildren: 0.08
Regla 6
Modals: accesibilidad completa
Todo modal necesita: focus trap, cierre con Escape, scroll lock, role="dialog", aria-modal="true".
role="dialog" aria-modal
Regla 7
Scroll reveals: once: true
Usa siempre viewport={{ once: true }} en whileInView. Repetir la animación al salir del viewport es distractor.
viewport={{ once: true }}
Regla 8
Cero números crudos
Importa todos los valores de motion-foundations. Nunca uses duration: 0.3 ni scale: 1.05 inline.
motionTokens.duration.normal
🗺

Tabla de Decisión

¿Qué patrón usar según la situación?

Situación Patrón Notas clave
Elemento aparece / desaparece AnimatePresence key en hijo directo, exit siempre
Lista de items en cascada stagger variants staggerChildren 0.05–0.10s
Navegación entre rutas PageTransition mode="wait", key=pathname
Elemento cambia de tamaño layout prop Solo en subárboles <5 hijos
Mismo elemento en dos contextos layoutId Crossfade automático
Entrada al hacer scroll whileInView once: true, margin: "-80px"
Valor ligado a posición scroll useScroll + useTransform scaleX, opacity, etc.
Notificaciones en pila AnimatePresence mode="sync" Solapamiento permitido
⚠️

Anti-patrones comunes

Los 8 errores más frecuentes que rompen animaciones en producción

Anti-patrón Regla Corrección
AnimatePresence sin key en el hijo R1 Añade key estable al hijo directo
initial + animate sin exit R2 Define siempre los tres juntos
Page transition sin mode="wait" R3 Añade mode="wait" a AnimatePresence
layout en lista de 50 items R4 Usa mode="popLayout" o transforms explícitos
staggerChildren: 0.2 en lista de 10 R5 Limitar a 0.08–0.10s
Modal sin focus trap ni Escape R6 Usar focus-trap-react o Radix Dialog
whileInView sin viewport={{ once: true }} R7 La animación se repite al entrar/salir — distractor
transition={{ duration: 0.3 }} inline R8 Usar motionTokens.duration.normal