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)
| Nombre | Valor | Cuándo |
|---|---|---|
EASE.out | cubic-bezier(0.16, 1, 0.3, 1) | El default de la marca (estilo Apple). Entradas, reveals, transición de página |
EASE.inOut | cubic-bezier(0.65, 0, 0.35, 1) | Movimientos que van y vuelven |
backOut | (preset de motion) | Entradas con rebote: la mascota, chips, badges |
Springs
| Contexto | Nombre | Valores | Cuándo |
|---|---|---|---|
| Web | SPRING | damping 20 · stiffness 100 | Spring por defecto |
| Web | magnético | damping 18 · stiffness 220 | Botón magnético (sigue el cursor) |
| Móvil | snappy | damping 18 · stiffness 220 | Micro-interacciones, botones |
| Móvil | bouncy | damping 12 · stiffness 180 | Entradas “pop” con rebote (XP, logros) |
| Móvil | gentle | damping 20 · stiffness 120 | Entradas “slide”, transiciones de pantalla |
Duraciones
| Token | ms | Cuándo |
|---|---|---|
instant | 120 | Feedback táctil (press) |
fast | 200 | Hovers, toggles |
base | 320 | Transiciones de UI |
slow | 500 | Entradas de sección, elementos protagonistas |
Estas constantes están portadas en
src/lib/motion.tsde este repo y en los design tokens (tokens.motion). Fuente original:pro-universitas-web/lib/animations.tsymobile/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 ↺:

Specs exactas (de LandingMascot.tsx / MascotAccent):
| Momento | Valores |
|---|---|
| Idle (flotación) | y: [0, -10, 0] · 3.4 s · easeInOut · loop ∞ (versión acento: [0, -6, 0] · 2.6 s) |
| Entrada en viewport | opacity 0→1 · scale 0.6→1 · rotate −10°→0° · 0.5 s · backOut · una sola vez |
| Hover | scale 1.08 · rotate 4° |
| Parallax en hero | al hacer scroll: y 0→−40 · rotate 0→−5° |
| Celebración | brincos cortos en y — nunca 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 }}>Reveals de sección (fadeUp + stagger)
Todo el contenido de landing entra con fade + slide-up y las listas en cascada:
// 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 conuseScroll({ 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/saveDatao 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á enpro-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 conmarginnegativo? - ¿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?