NutriTrack SaaS · Motion System

Motion System Foundation

Capa base de animaciones para el dashboard de NutriTrack. Tokens, springs, accesibilidad y SSR-safety en un solo lugar.

Stack Next.js 14 + motion/react
Versión 1.0.0
Skill bases-del-sistema-de-motion-para-react
Archivos 4 ficheros base
Vista previa — Dashboard NutriTrack con el sistema de motion
NutriTrack Panel de Pacientes · Clínica Salud Madrid
springs.gentle
Pacientes Activos
847
↑ 12% este mes
springs.snappy
Sesiones Hoy
34
4 en curso ahora
springs.gentle
Adherencia Media
82%
Meta: 85%

Cada tarjeta usa FadeInCard con shouldAnimate() + mount guard SSR-safe

Tokens de duración — motionTokens.duration
⏱ Duration Scale
lib/motion-tokens.ts
instant 0.08s
Tooltip, focus ring, badge
fast 0.18s
Button feedback, chip toggle
normal 0.35s
Modal open, card expand, page enter
slow 0.60s
Hero entrance, page transition
crawl 1.00s
Storytelling — usar con moderación
〜 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 4px
Micro-desplazamientos
sm 8px
Elementos inline
md 16px
Cards, paneles (default)
lg 24px
Secciones de página
xl 48px
Hero, transiciones globales
⬡ Scale
NT
subtle
whileHover en tarjetas sutiles
0.98
NT
press
whileTap en botones
0.95
NT
pop
whileHover en cards clave
1.04
Spring Presets — springs map
snappy
k: 300 d: 30
UI default — botones, chips, nav items
gentle
k: 120 d: 14
Cards, modales, paneles de NutriTrack
bouncy
k: 400 d: 10
Onboarding, estados vacíos
instant
k: 600 d: 35
Tooltips, popovers, dropdowns
release
k: 200 d: 20
Drag release, física natural
📁 lib/motion-tokens.ts
TS
// 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 },
}
📁 components/fade-in-card.tsx
TSX
"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>
  )
}
⚡ 8 Reglas Innegociables
R1
Solo motion/react Nunca importar desde framer-motion. Nunca mezclar en el mismo árbol.
R2
initial = render del servidor Si el servidor renderiza opacity:1, initial también. Usa mount guard.
R3
Reduced-motion sobre todo useReducedMotion=true → 0 transforms. Solo fade de opacidad ≤0.2s.
R4
No animar layout width/height/margin/padding en animate están prohibidos. Usa transform.
R5
Duraciones vía tokens Prohibido duration: 0.4 inline. Usa motionTokens.duration.normal.
R6
Springs vía presets Prohibido stiffness/damping inline. Usa springs.snappy.
R7
"use client" obligatorio Todo archivo que importe de motion/react necesita la directiva.
R8
No window/navigator a nivel módulo Siempre guardar con typeof window !== "undefined".
♿ Prioridad de Accesibilidad
1
prefers-reduced-motion: reduce

Deshabilita todos los transforms. Solo fades de opacidad ≤0.2s. Prioridad absoluta (pacientes con epilepsia fotosensible en NutriTrack).

2
Detección de dispositivo bajo

hardwareConcurrency ≤4 → reduce duración, elimina animaciones no esenciales (tablets de clínica).

3
Preferencia de diseño

Todo lo demás: tokens, springs, efectos visuales. Solo se aplica si las capas superiores no lo anulan.

📁 lib/motion-config.ts
TS
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-patrones a evitar
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"