NutriFlow — Motion UI System

Sistema de Motion UI Unificado · Stack: Next.js 14 + motion/react

v1.0 · production
Principio rector: la animación debe guiar atención, comunicar estado o preservar continuidad. Si no hace ninguna → eliminar.
Motion is interaction design · responsiveness > smoothness · support prefers-reduced-motion always
1
DashboardCard — fade-in con stagger
whileInView + staggerChildren ≤ 0.1s · comunica «datos disponibles» progresivamente
stagger: 0.08s
👥
Pacientes activos
1,284
↑ +12% vs. mes anterior
📋
Consultas hoy
47
↑ +3 vs. ayer
⚠️
Alertas pendientes
9
↓ –2 vs. ayer
💊
Planes activos
632
↑ +5% mensual
// motionTokens.ts — tokens centralizados, nunca hardcodear valores export const motionTokens = { duration: { fast: 0.18, normal: 0.35, slow: 0.6 }, easing: { smooth: [0.22, 1, 0.36, 1] as [number, number, number, number], sharp: [0.4, 0, 0.2, 1] as [number, number, number, number] }, distance: { sm: 8, md: 16, lg: 24 } } // DashboardCard — stagger: 0.08s (≤ 0.1s regla), usa whileInView const container = { hidden: {}, visible: { transition: { staggerChildren: 0.08 } } } const card = { hidden: { opacity: 0, y: motionTokens.distance.md }, visible: { opacity: 1, y: 0, transition: { duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth } } } export function DashboardGrid() { return ( <motion.ul variants={container} initial="hidden" whileInView="visible" viewport={{ once: true }}> {metrics.map(m => <motion.li key={m.id} variants={card}><Card {...m} /></motion.li>)} </motion.ul> ) }
2
ActionButton — feedback hover / tap
whileHover scale(1.025) · whileTap scale(0.97) · transiciones sobre transform+opacity únicamente
whileHover · whileTap
// ActionButton — safe properties: transform + opacity únicamente, nunca width/height export function ActionButton({ children, variant }: ButtonProps) { return ( <motion.button whileHover={{ scale: 1.025, y: -1 }} whileTap={{ scale: 0.97 }} transition={{ duration: motionTokens.duration.fast, ease: motionTokens.easing.sharp }} > {children} </motion.button> ) }
3
PatientModal — AnimatePresence mode="wait"
Exit completa antes de Enter · focus trap · scroll lock · Escape close · ARIA dialog
mode="wait"
// PatientModal — AnimatePresence mode="wait": exit completa ANTES de enter // ✓ focus trap ✓ scroll lock ✓ Escape close ✓ ARIA roles <AnimatePresence mode="wait"> {open && ( <motion.div role="dialog" aria-modal="true" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }} /> )} </AnimatePresence> // ⚠ ANTES (bug): sin AnimatePresence → modales se solapaban visualmente // ✓ AHORA: exit animation completa antes de que entre el siguiente modal
4
DataSkeletons — estados de carga
Pulso de opacidad en loop · comunica «datos en camino» sin spinner agresivo · stagger por delay
opacity pulse · 1.5s
Planes de la semana
Próximas consultas
// DataSkeleton — pulso de opacidad en loop infinito // Safe: sólo anima opacity (no layout properties) export function DataSkeleton() { return ( <motion.div className="h-3 w-full rounded bg-slate-700" animate={{ opacity: [0.5, 1, 0.5] }} transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} /> ) } // Regla: duration 1.5s (cómodo), repeat: Infinity sólo en skeletons/progress // ⚠ Infinite animations: siempre preguntar qué estado comunican
5
motionTokens.ts — sistema de tokens unificado
Un único origen de verdad para duraciones, easing y distancias
@/lib/motionTokens

Duration

duration.fast 0.18s
duration.normal 0.35s
duration.slow 0.6s

Easing

easing.smooth → ease-out
[0.22, 1, 0.36, 1]
easing.sharp → material
[0.4, 0, 0.2, 1]

Distance (y offset)

distance.sm 8px
distance.md 16px
distance.lg 24px
Safe props: transform, opacity
Avoid: width, height, top, left
6
QA Checklist — NutriFlow Motion Audit
Verificación post-implementación de todos los patrones aplicados
Sin CLS (Content Layout Shift) en ningún componente
Navegación completa por teclado operativa
Focus trap activo en PatientModal
ARIA roles correctos (role="dialog" + aria-modal)
prefers-reduced-motion respetado (JS + CSS)
Sin warnings de hidratación en Next.js App Router
Animaciones detienen en unmount (sin memory leaks)
AnimatePresence mode declarado explícitamente en todos los usos
7
Anti-patrones corregidos en NutriFlow
Bugs encontrados en el código legacy y cómo se resolvieron
Import mixto — framer-motion y motion/react en el mismo proyecto causaban schedulers conflictivos y AnimatePresence roto. Migrado todo a motion/react.
Stagger excesivo 0.3s — listas con 8+ items tardaban +2.4s en completarse. Reducido a 0.08s (regla: ≤ 0.1s).
Modales sin mode="wait" — al cambiar modal rápido, enter/exit se solapaban visualmente. Añadido mode="wait" en todos los AnimatePresence de modales.
reduced-motion ignorado — ningún componente consultaba useReducedMotion. Añadido hook + CSS @media en todos los componentes animados.
Animando width/height — 3 componentes animaban dimensiones causando reflow. Reemplazado por scale + opacity (transform-only).
layout prop en contenedor full-viewport — causaba jank visible en el dashboard principal. Eliminado; se usa CSS Grid transitions en su lugar.