HTML Motion Graphics
Construye gráficos en movimiento deterministas de calidad cinematográfica como un archivo index.html independiente sin servidor, impulsado por GSAP, desenfoque de movimiento SVG direccional, un rig de cámara unificado #world y fondos opcionales de Three.js WebGL.
Cuándo Usarlo
- Crear un video promocional, apertura de lanzamiento de producto, avance o bumper de marca en
index.html. - Construir tipografía cinética, recorridos de UI de productos SaaS o explicadores visuales de múltiples escenas.
- Convertir animaciones web estáticas similares a diapositivas en diseño de movimiento real impulsado por cámara.
- Entregar un
index.htmlportátil que se abra directamente a través defile://y admita desplazamiento de fotogramas preciso y renderizado MP4 sin cabeza.
Flujo de Trabajo Principal
Paso 1: Fijar el Brief Creativo y el Concepto del Fotograma
Antes de escribir código, define un breve estilo conciso para que el resultado nunca parezca una plantilla de diapositivas genérica:
- Relación de Aspecto y Resolución: Por defecto
1920x1080(16:9) para escritorio/YouTube, o1080x1920(9:16) para Reels/TikTok/Shorts (envuelve el contenido en un contenedor.safeescalado a0.78contransform-origin: 50% 45%para el espacio libre HUD social 9:16). - Arquitectura del Concepto: Elige una estructura conceptual clara basada en el tema en lugar de usar por defecto
gancho -> 3 mosaicos de iconos -> CTA:- Viaje Continuo por el Mundo / Cámara: Un espacio unificado donde la cámara hace dolly, orbita o se sumerge a través de escenas.
- Recorrido de UI en Vivo: Componentes de producto DOM/SVG reconstruidos donde un cursor/toque interactúa, los paneles se expanden y los datos se animan de forma determinista.
- Problema a Claridad: El caos visual se acumula, luego una sola acción del producto limpia el encuadre dejando la marca de la empresa.
- Tipografía Cinética Pura: Las palabras entran desde varios ejes, cambian de peso/escala y se ensamblan en la marca final.
- Dibujo Animado / Acción Continua: El personaje o sujeto principal permanece activo mientras los fondos, instrumentos y etiquetas diegéticas cambian a su alrededor.
- Sistema de Color y Superficie: Define propiedades personalizadas de CSS (
--base,--ink,--accent,--accent-lit). Nunca dejes un fondo de un solo color plano. Combina al menos dos capas (por ejemplo, resplandor de horizonte radial + gradiente lineal, manchas suaves de malla o nebulosa WebGL + polvo a la deriva + superposición de grano de película SVG). - Plan de Audio y Temporización: Fija la duración total (
DURATIONen segundos) y asigna cada tiempo fuerte a marcas de tiempo absolutas en segundos.
Paso 2: Construir la Arquitectura del index.html Independiente
Entrega un solo index.html autocontenido con <style> en línea y <script type="module"> que importe GSAP (y Three.js si se necesita profundidad 3D/partículas) desde https://esm.sh o https://cdn.jsdelivr.net.
- Escenario de Resolución Fija: Bloquea
#stagea1920x1080(o1080x1920) y escala proporcionalmente para ajustarse a cualquier viewport mediante CSS--fitpara que las coordenadas de composición nunca cambien entre monitores. - Rig de Cámara Unificado
#world: Coloca<canvas>(si es WebGL), gradientes de fondo y capas de escena#domdentro de#world. Cuando la cámara hace zoom o paneo, tanto el fondo como el primer plano se mueven juntos. Coloca las superposiciones de lente (fugas de luz, viñeta, grano de película) fuera de#world. - Estados Ocultos Primero en CSS: Establece
opacity: 0en los elementos animados (.line,.ch,.hl-t,.obj) en CSS y usaimmediateRender: falseen los tweensfromTode GSAP para evitar destellos en el primer fotograma.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Motion Graphic</title>
<style>
:root {
--base: #070913;
--ink: #f8fafc;
--accent: #38bdf8;
--accent-lit: #bae6fd;
--fit: 1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: system-ui, -apple-system, sans-serif; }
#stage {
position: fixed; left: 50%; top: 50%; width: 1920px; height: 1080px;
transform: translate(-50%, -50%) scale(var(--fit));
overflow: hidden; background: var(--base); color: var(--ink);
}
#world { position: absolute; inset: -240px; transform-origin: 50% 50%; will-change: transform; }
.bg-layer {
position: absolute; inset: 0;
background:
radial-gradient(70% 45% at 50% 105%, color-mix(in srgb, var(--accent) 32%, transparent), transparent 70%),
linear-gradient(180deg, #04060c 0%, var(--base) 60%, #0d1326 100%);
}
.scene { position: absolute; inset: 240px; display: grid; place-items: center; pointer-events: none; }
.line { font-size: 112px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.1; white-space: nowrap; opacity: 0; text-shadow: 0 6px 32px rgba(0,0,0,0.55); }
.wd { display: inline-block; white-space: nowrap; }
.ch { display: inline-block; opacity: 0; will-change: transform, opacity, filter; }
.vignette { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle at 50% 50%, transparent 58%, rgba(0,0,0,0.65) 100%); }
.grain { position: absolute; inset: -60px; pointer-events: none; filter: url(#grain); opacity: 0.16; mix-blend-mode: overlay; }
#hud { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); display: none; gap: 10px; align-items: center; background: rgba(15,23,42,0.85); padding: 8px 14px; border-radius: 999px; color: #fff; font: 13px/1 monospace; z-index: 99; }
#hud.show { display: flex; }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.75" numOctaves="3" stitchTiles="stitch" />
<feColorMatrix type="saturate" values="0" />
</filter>
</defs>
</svg>
<div id="stage">
<div id="world">
<div class="bg-layer"></div>
<div class="scene" id="s1"><h1 class="line" id="t1">Motion without limits</h1></div>
</div>
<div class="vignette"></div>
<div class="grain" id="grain"></div>
</div>
<div id="hud">
<button id="btnPlay">Pause</button>
<input id="scrub" type="range" min="0" max="1" step="0.001" value="0" style="width:260px" />
<span id="clock">0.00s</span>
</div>
<script type="module">
import gsap from "https://esm.sh/[email protected]";
const W = 1920, H = 1080, DURATION = 6.0;
const stage = document.getElementById("stage");
const world = document.getElementById("world");
const grain = document.getElementById("grain");
function fitStage() {
const s = Math.min(window.innerWidth / W, window.innerHeight / H);
stage.style.setProperty("--fit", s);
}
window.addEventListener("resize", fitStage);
fitStage();
function splitChars(el) {
const text = el.textContent.trim();
el.innerHTML = text.split(/\s+/).map(word =>
`<span class="wd">${[...word].map(c => `<span class="ch">${c}</span>`).join("")}</span>`
).join(" ");
return el.querySelectorAll(".ch");
}
function blurTween(tl, target, at, fromV, toV, dur, axis = "x", ease = "power3.out") {
const id = "mb_" + Math.random().toString(36).slice(2, 8);
const defs = document.querySelector("svg defs");
const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter");
filter.setAttribute("id", id);
filter.setAttribute("x", "-70%"); filter.setAttribute("y", "-70%");
filter.setAttribute("width", "240%"); filter.setAttribute("height", "240%");
const blur = document.createElementNS("http://www.w3.org/2000/svg", "feGaussianBlur");
blur.setAttribute("stdDeviation", "0 0");
filter.appendChild(blur);
defs.appendChild(filter);
const proxy = { v: fromV };
tl.set(target, { filter: `url(#${id})` }, at);
tl.to(proxy, {
v: toV, duration: dur, ease,
onUpdate() {
const a = proxy.v.toFixed(2), b = (proxy.v * 0.08).toFixed(2);
blur.setAttribute("stdDeviation", axis === "x" ? `${a} ${b}` : `${b} ${a}`);
},
onComplete() { target.style.filter = "none"; }
}, at);
}
const CAM = { s: 1, fx: W / 2, fy: H / 2 };
function applyCam(t) {
const breathe = CAM.s * (1 + 0.012 * Math.sin(t * 0.9));
const tx = W / 2 - CAM.fx * breathe;
const ty = H / 2 - CAM.fy * breathe;
world.style.transform = `translate(${tx}px, ${ty}px) scale(${breathe})`;
const k = Math.floor(t * 12);
grain.style.transform = `translate(${(k * 37 % 60) - 30}px, ${(k * 61 % 60) - 30}px)`;
}
const tl = gsap.timeline({
paused: true,
onUpdate() { applyCam(tl.time()); }
});
await document.fonts.ready;
const t1 = document.getElementById("t1");
const chars1 = splitChars(t1);
tl.set(t1, { opacity: 1 }, 0.2);
tl.fromTo(chars1,
{ opacity: 0, yPercent: 65, scaleY: 1.4 },
{ opacity: 1, yPercent: 0, scaleY: 1, duration: 0.85, ease: "power4.out", stagger: 0.022, immediateRender: false },
0.2
);
blurTween(tl, t1, 0.2, 28, 0, 0.85, "y", "power4.out");
tl.to(CAM, { s: 1.08, duration: 3.0, ease: "sine.inOut" }, 0.2);
tl.to(chars1, { opacity: 0, yPercent: -65, duration: 0.42, ease: "power2.in", stagger: 0.01 }, 4.6);
blurTween(tl, t1, 4.6, 0, 42, 0.48, "y", "power2.in");
tl.to({}, { duration: 0.01 }, DURATION);
const params = new URLSearchParams(location.search);
const isClean = params.get("clean") === "1";
const isDebug = params.get("debug") === "1";
const hud = document.getElementById("hud");
const scrub = document.getElementById("scrub");
const clock = document.getElementById("clock");
const btnPlay = document.getElementById("btnPlay");
if (isDebug) hud.classList.add("show");
function seek(t) {
const clamped = Math.max(0, Math.min(DURATION, t));
tl.pause(clamped, false);
gsap.ticker.tick();
applyCam(clamped);
scrub.value = clamped / DURATION;
clock.textContent = `${clamped.toFixed(2)}s`;
}
tl.eventCallback("onUpdate", () => {
const t = tl.time();
applyCam(t);
scrub.value = t / DURATION;
clock.textContent = `${t.toFixed(2)}s`;
});
tl.eventCallback("onComplete", () => { if (!isClean) tl.restart(); });
btnPlay.onclick = () => {
if (tl.paused()) { tl.play(); btnPlay.textContent = "Pause"; }
else { tl.pause(); btnPlay.textContent = "Play"; }
};
scrub.oninput = () => seek(parseFloat(scrub.value) * DURATION);
window.addEventListener("keydown", e => {
if (e.code === "Space") { e.preventDefault(); btnPlay.click(); }
if (e.code === "KeyR") { tl.restart(); }
});
window.OPENER = { tl, DURATION, seek };
applyCam(0);
if (!isClean) tl.play(0);
</script>
</body>
</html>
Paso 3: Aplicar Reglas de Diseño de Movimiento Anti-PPT
Aplica estas reglas de artesanía en cada escena para que el resultado se lea como diseño de movimiento en lugar de una presentación de diapositivas animada:
- Una Declaración por Tiempo Fuerte: Mantén los titulares de 2 a 5 palabras en minúscula (peso 500-600). Nunca acumules un subtítulo + título de 2 líneas + cuerpo de 2 líneas + 4 insignias de iconos en una cuadrícula estática.
- Temporización Asimétrica: Entra con una desaceleración decisiva (
0.7-1.1s,power4.outoback.out(1.5)) y sale más rápido (0.35-0.5s,power2.in). Superpone los elementos salientes y entrantes por0.15-0.3spara que la pantalla nunca se quede muerta. - Desenfoque de Movimiento SVG Direccional: Usa
feGaussianBlurconstdDeviation="x y"dividido alineado con la dirección del movimiento, expandiendo la región del filtro (x="-70%" y="-70%" width="240%" height="240%") para que las colas de desenfoque nunca se recorten. Elimina el filtro (filter: none) al completarse. - Movimiento de Cámara Motivado: Combina una respiración sutil continua de la cámara (
+/-1-3%deriva sinusoidal) con cambios de toma intencionados (plano general -> plano medio corto -> primer plano del elemento activo). Nunca panees una escena tan lejos que los bordes del fondo expongan barras negras vacías (proporciona240px+de sobreexploración en#world). - Contraste Primero: Mantén el texto del título nítido (
#fffcon sombra de legibilidad suave, sin floración de neón pesada de múltiples capas en el texto). Reserva el resplandor y el brillo para objetos principales, aspectos destacados de la interfaz de usuario y acentos 3D.
Paso 4: Garantizar el 100% de Determinismo
Cada estado visual debe ser una función pura de t = tl.time() para que el desplazamiento (?debug=1) y el renderizado MP4 sin cabeza produzcan fotogramas idénticos:
- Prohibido en el Bucle de Renderizado: Nunca uses
Math.random(),Date.now(),performance.now(),setIntervalo integración acumulativa (x += vx * dt). - Reemplazos Deterministas: Impulsa partículas, compensaciones de grano de película y oscilaciones usando constantes de fase con semilla y
Math.sin(t * freq + seed)o proxies de distancia interpolados (flow). - Colocación Absoluta en la Línea de Tiempo: Programa tweens usando marcas de tiempo numéricas explícitas (
tl.to(..., 2.40)) en lugar de desplazamientos relativos encadenados (+=0.3) para que ajustar un tiempo fuerte nunca rompa la sincronización de audio posterior.
Paso 5: Verificar Visualmente y Exportar (Cuando se Solicite)
- Inspección de Marca de Tiempo Clave: Abre
index.html?debug=1en un navegador (o captura capturas de pantalla a través de Playwright/Puppeteer usandowindow.OPENER.seek(t)) en cada pico de escena y punto medio de transición. Verifica que no haya recorte de texto, alto contraste entre el primer plano y el fondo, y superposiciones suaves. - Canalización de Exportación MP4: Cuando el usuario solicite un archivo de video, renderiza fotogramas deterministas usando
?clean=1ywindow.OPENER.seek(frame / fps)a 60 fps, luego codifica con FFmpeg:
ffmpeg -framerate 60 -i frames/f%05d.png -c:v libx264 -pix_fmt yuv420p -crf 14 -tune grain output.mp4
Problemas Comunes
- FOUC Antes de la Carga de la Fuente: Dividir el texto en espacios
.chantes de que las fuentes web terminen de cargarse calcula mal los cuadros delimitadores de los glifos. Siempre usaawait document.fonts.readyantes de ejecutarsplitChars(). transformOriginde GSAP en Grupos SVG: Animarrotationoscaleen elementos<g>anidados desplaza su cuadro delimitador y desacopla las uniones. Controla las transformaciones SVG a través de un objeto proxy JS que escribatranslate(x,y) rotate(r) scale(sx,sy)directamente enonUpdate.- CORS de Módulo en
file://:<script type="module" src="...">externo falla cuando se abre directamente desde el disco debido a las reglas CORS de origennull. Mantén CSS y JS en línea dentro deindex.htmlmientras importas librerías desde CDN HTTPS. - Limitación de
requestAnimationFrameen Pestaña Oculta: Los navegadores sin cabeza limitanrAFen pestañas en segundo plano. Siempre llama agsap.ticker.tick()yapplyCam(t)sincrónicamente dentro dewindow.OPENER.seek(t).