Capa base de animaciones para el dashboard de NutriTrack. Tokens, springs, accesibilidad y SSR-safety en un solo lugar.
Cada tarjeta usa FadeInCard con shouldAnimate() + mount guard SSR-safe
// NutriTrack · Motion Tokens // Fuente única de verdad para todo el equipo export const motionTokens = { duration: { instant: 0.08, // tooltip, badge fast: 0.18, // button, chip normal: 0.35, // modal, card slow: 0.60, // hero, page crawl: 1.00, // storytelling }, easing: { smooth: [0.22, 1, 0.36, 1], sharp: [0.4, 0, 0.2, 1], bounce: [0.34, 1.56, 0.64, 1], linear: [0, 0, 1, 1], }, distance: { xs: 4, sm: 8, md: 16, lg: 24, xl: 48, }, scale: { subtle: 0.98, press: 0.95, pop: 1.04, }, } export const springs = { snappy: { type: "spring", stiffness: 300, damping: 30 }, gentle: { type: "spring", stiffness: 120, damping: 14 }, bouncy: { type: "spring", stiffness: 400, damping: 10 }, instant: { type: "spring", stiffness: 600, damping: 35 }, release: { type: "spring", stiffness: 200, damping: 20, restDelta: 0.001 }, }
"use client" // ← Regla 7: obligatorio import { useState, useEffect } from "react" import { motion } from "motion/react" // ← R1: nunca framer import { motionTokens, springs } from "@/lib/motion-tokens" import { useSafeMotion } from "@/hooks/use-reduced-motion" import { motionConfig } from "@/lib/motion-config" export function FadeInCard({ children, delay = 0 }) { // SSR guard — initial debe = render servidor (R2) const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) // Accesibilidad (R3): sin transform si prefersReduced const safe = useSafeMotion(motionTokens.distance.md) // Gate: no animar en dispositivos bajos / no montado if (!motionConfig.shouldAnimate() || !mounted) return <div>{children}</div> return ( <motion.div initial={safe.initial} animate={safe.animate} exit={safe.exit} transition={{ ...springs.gentle, delay }} whileHover={{ scale: motionTokens.scale.pop }} whileTap={{ scale: motionTokens.scale.press }} > {children} </motion.div> ) }
framer-motion. Nunca mezclar en el mismo árbol.
duration: 0.4 inline. Usa motionTokens.duration.normal.
springs.snappy.
typeof window !== "undefined".
Deshabilita todos los transforms. Solo fades de opacidad ≤0.2s. Prioridad absoluta (pacientes con epilepsia fotosensible en NutriTrack).
hardwareConcurrency ≤4 → reduce duración, elimina animaciones no esenciales (tablets de clínica).
Todo lo demás: tokens, springs, efectos visuales. Solo se aplica si las capas superiores no lo anulan.
shouldAnimate({ essential = false } = {}) { // Prioridad 1: accesibilidad if (this.prefersReduced()) return false // Prioridad 2: hardware bajo (no esenciales) if (!essential && this.isLowEnd()) return false // Prioridad 3: animar return true }
| Anti-patrón | Regla | Corrección |
|---|---|---|
| import { motion } from "framer-motion" | R1 | from "motion/react" |
| initial={{ opacity: 0 }} en componente SSR | R2 | mount guard + initial condicional |
| Omitir useReducedMotion | R3 | useSafeMotion() hook |
| animate={{ width: "100%" }} | R4 | scaleX transform |
| transition={{ duration: 0.4 }} | R5 | motionTokens.duration.normal |
| { stiffness: 300, damping: 30 } inline | R6 | springs.snappy |
| Archivo sin "use client" | R7 | Añadir directiva al inicio |
| navigator.hardwareConcurrency a nivel módulo | R8 | typeof navigator !== "undefined" |