⚡
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
👥
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>
)
}
// 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>
)
}
← Prueba las transiciones de modal
24 jun 2026
Dieta mediterránea + restricción calórica 1.800 kcal/día
Evolución de peso (kg)Objetivo: 62 kg
// 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
// 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
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
✓ 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
✕
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.