Button Feedback
Escala en hover/tap usando tokens de motion — sin números inline
"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> ) }
Stagger List
Lista de pacientes que aparece en cascada al montar — staggerChildren 0.08s
"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> ) }
-
AMActivoAna MartínezPlan pérdida de peso · Sesión hoy
-
CLPremiumCarlos LópezPlan masa muscular · Sesión mañana
-
SGAlertaSofia GarcíaDiabetes tipo 2 · Seguimiento semanal
-
MPNuevoMiguel PérezRendimiento deportivo · Nuevo
Toast Stack
Notificaciones apiladas con entrada desde la derecha — mode="sync" para solapamiento
"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>
Modal con AnimatePresence
Confirmación de borrado — overlay + panel, exit siempre definido, accesibilidad completa
// 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} /> </> ) }
Expanding Card (layout + AnimatePresence)
Card de macros del día expandible — layout en el contenedor, AnimatePresence en el body
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> ) }
Page Transition (Next.js App Router)
Wrapper de transición entre rutas — mode="wait" para que exit termine antes de enter
// 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> ) }
Scroll Reveal
Elementos que aparecen al entrar en viewport — viewport={{ once: true }} obligatorio
"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, }} /> ))}
Scroll Progress Bar
Barra de progreso de lectura ligada a scrollYProgress — useScroll + useTransform
"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
8 Reglas de Oro
Violaciones silenciosas que rompen las animaciones en producción
AnimatePresence. El hijo directo siempre necesita key. Sin key, el exit nunca se dispara.exit es incompleta. Define los tres siempre juntos: initial, animate, exit.mode="wait" garantiza que el exit termina antes de que empiece el enter. Evita contenido superpuesto.layout en listas de más de ~5 hijos o DOM muy anidado. Usa mode="popLayout" o transforms explícitos.role="dialog", aria-modal="true".viewport={{ once: true }} en whileInView. Repetir la animación al salir del viewport es distractor.motion-foundations. Nunca uses duration: 0.3 ni scale: 1.05 inline.Tabla de Decisión
¿Qué patrón usar según la situación?
Anti-patrones comunes
Los 8 errores más frecuentes que rompen animaciones en producción