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"