PROUniversitas· Marca

Animaciones

El sistema de movimiento de la marca — física (easings, springs, duraciones), patrones reales de la web y la app, la mascota animada y la técnica del hero por scroll. Con demos en vivo y código de producción.

El movimiento es parte de la identidad: sutil, con rebote amable y física consistente. Esta página documenta el sistema real implementado en pro-universitas-web (librería motion/react, ex Framer Motion) y en la app de aprendizaje-adaptativo-v2 (web con framer-motion, móvil con Reanimated). Todo lo que ves acá corre con las mismas constantes que producción.

La física de la marca

Una sola "física" compartida. Si animas algo nuevo, usa estos valores — no inventes curvas.

Easings (web)

NombreValorCuándo
EASE.outcubic-bezier(0.16, 1, 0.3, 1)El default de la marca (estilo Apple). Entradas, reveals, transición de página
EASE.inOutcubic-bezier(0.65, 0, 0.35, 1)Movimientos que van y vuelven
backOut(preset de motion)Entradas con rebote: la mascota, chips, badges

Springs

ContextoNombreValoresCuándo
WebSPRINGdamping 20 · stiffness 100Spring por defecto
Webmagnéticodamping 18 · stiffness 220Botón magnético (sigue el cursor)
Móvilsnappydamping 18 · stiffness 220Micro-interacciones, botones
Móvilbouncydamping 12 · stiffness 180Entradas “pop” con rebote (XP, logros)
Móvilgentledamping 20 · stiffness 120Entradas “slide”, transiciones de pantalla

Duraciones

TokenmsCuándo
instant120Feedback táctil (press)
fast200Hovers, toggles
base320Transiciones de UI
slow500Entradas de sección, elementos protagonistas

Estas constantes están portadas en src/lib/motion.ts de este repo y en los design tokens (tokens.motion). Fuente original: pro-universitas-web/lib/animations.ts y mobile/constants/theme.ts.

La mascota animada

El chungungo flota en reposo, entra con rebote y reacciona al hover. Pasa el cursor y repite la entrada con el botón ↺:

El chungungo (demo de animación)

Specs exactas (de LandingMascot.tsx / MascotAccent):

MomentoValores
Idle (flotación)y: [0, -10, 0] · 3.4 s · easeInOut · loop ∞ (versión acento: [0, -6, 0] · 2.6 s)
Entrada en viewportopacity 0→1 · scale 0.6→1 · rotate −10°→0° · 0.5 s · backOut · una sola vez
Hoverscale 1.08 · rotate 4°
Parallax en heroal hacer scroll: y 0→−40 · rotate 0→−5°
Celebraciónbrincos cortos en ynunca spins de 360° ni escalas bruscas

Código real (framer-motion), listo para copiar:

// Idle + parallax (LandingMascot.tsx — aprendizaje-adaptativo-v2)
const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end start'] });
const y = useTransform(scrollYProgress, [0, 1], [0, -40]);
const rotate = useTransform(scrollYProgress, [0, 1], [0, -5]);

<motion.div style={{ y, rotate }}
  animate={{ y: [0, -10, 0] }}
  transition={{ duration: 3.4, ease: 'easeInOut', repeat: Infinity }}>
  <Mascot mood="celebra" />
</motion.div>

// Entrada + hover (MascotAccent)
<motion.div
  initial={{ opacity: 0, scale: 0.6, rotate: -10 }}
  whileInView={{ opacity: 1, scale: 1, rotate: 0 }}
  viewport={{ once: true, margin: '-100px' }}
  transition={{ duration: 0.5, ease: 'backOut' }}
  whileHover={{ scale: 1.08, rotate: 4 }}>
En sesión activa (pregunta en pantalla) el chungungo no se mueve — no distrae la lectura.

Reveals de sección (fadeUp + stagger)

Todo el contenido de landing entra con fade + slide-up y las listas en cascada:

Diagnóstico
Plan adaptativo
Feedback
// lib/animations.ts (pro-universitas-web) — usar tal cual
export const fadeUp = {
  hidden: { opacity: 0, y: 24 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] } },
};
export const staggerContainer = {
  hidden: {},
  visible: { transition: { staggerChildren: 0.1, delayChildren: 0.1 } },
};

<motion.section variants={staggerContainer} initial="hidden" whileInView="visible"
  viewport={{ once: true, margin: '-80px' }}>
  {items.map((i) => <motion.div key={i} variants={staggerItem}>…</motion.div>)}
</motion.section>

Titulares palabra a palabra (TextReveal)

Los H1 de la landing se revelan palabra por palabra (stagger 0.06 s), con las palabras clave en amarillo de marca:

El preu que se adapta a ti

// TextReveal.tsx (pro-universitas-web): reparte el texto en <motion.span> por palabra
<motion.span initial="hidden" whileInView="visible" transition={{ staggerChildren: 0.06 }}>
  {words.map((w, i) => (
    <motion.span key={i} variants={{ hidden: { opacity: 0, y: 18 },
      visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: EASE.out } } }}
      className={esAcento(w) ? 'text-brand-yellow' : undefined}>{w} </motion.span>
  ))}
</motion.span>

Botones: 3D + magnético

El botón de marca es 3D (sombra dura del color dark, se hunde al presionar) y en desktop puede ser magnético (se atrae al cursor con spring 18/220, fuerza 0.4):

/* Botón 3D (DESIGN.md) */
.btn-3d { box-shadow: 0 4px 0 var(--amarillo-dark); border-radius: 16px; font-weight: 800; }
.btn-3d:active { transform: translateY(2px); box-shadow: 0 2px 0 var(--amarillo-dark); }

Transición de página

Cada navegación entra con un fade + slide sutil (app/template.tsx de la web):

<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}>{children}</motion.div>

El hero por scroll (scroll-driven sequence)

La animación más distintiva del sitio: el hero de preucv.cl reproduce una secuencia de 273 frames webp controlada por el scroll (la corredora avanza a medida que bajas). No es un video: es un <canvas> que dibuja el frame correspondiente al progreso de scroll.

Arquitectura (código en pro-universitas-web):

  • hero/useHeroFrames.tsx — precarga compartida de los frames (hero y preloader leen el mismo progreso y el mismo array; una sola descarga). Expone { images, progress, ready, degraded }.
  • HeroScrollSequence.tsx — sección sticky con useScroll({ offset: ['start start','end end'] }); mapea el progreso [0, 0.92] linealmente a los frames y deja el último frame fijo en [0.92, 1].
  • Decode-ahead: decodifica img.decode() 24 frames por delante en la dirección del scroll para que el canvas nunca dibuje un frame sin decodificar (evita jank).
  • Capas: <img> estático de respaldo → <canvas> con el frame → overlays de texto/CTA.
  • Degradación: con prefers-reduced-motion, red 2G/saveData o error de carga, muestra un póster estático en vez del scrub.
// Esqueleto del patrón
const { scrollYProgress } = useScroll({ target: sectionRef, offset: ['start start', 'end end'] });
useMotionValueEvent(scrollYProgress, 'change', (p) => {
  const frame = Math.min(FRAME_COUNT - 1, Math.round((p / 0.92) * (FRAME_COUNT - 1)));
  ensureDecoded(frame);           // + decode-ahead en la dirección del scroll
  drawFrame(canvasRef.current, images[frame]);
});

Los frames de esa secuencia (una persona corriendo en la pista) son material del hero del sitio, no un asset de la mascota — por eso no viven en public/brand/. Si necesitas la secuencia, está en pro-universitas-web/public/frames/{run,run-mobile}/.

Accesibilidad: reduced motion

Regla obligatoria: todo componente animado respeta prefers-reduced-motion — devuelve el contenido estático (la web usa useReducedMotion() de motion; el hero cae a póster). Los demos de esta página lo cumplen.

Checklist al animar algo nuevo

  • ¿Usaste EASE.out / los springs de la tabla (no curvas inventadas)?
  • ¿La entrada es una sola vez (viewport: { once: true }) y con margin negativo?
  • ¿Respeta prefers-reduced-motion?
  • ¿En sesión de práctica no hay nada moviéndose?
  • ¿La mascota sigue sus reglas (ver mascota) — sin spins, sin escalas bruscas?

On this page