Gráficos en movimiento con scroll
Una línea de tiempo en canvas impulsada por la posición del scroll: un escenario anclado, carretes que son funciones puras del progreso, y subtítulos que el dibujo nunca tiene permitido tocar. Este método impulsa el tambor narrativo en la página de inicio de ImuTechz, el tambor de I+D y la película del recorrido del fundador. También alimenta la demostración de cinco carretes en assets/demo.html.
Toda la técnica cabe en una frase. La posición del scroll es el único reloj: se asigna a p ∈ [0, 1], y cada píxel, subtítulo y rotación es una función de p.
Cómo se ve un buen resultado
- Se desplaza como un video. Desplázate hacia adelante y la película se reproduce. Desplázate hacia atrás y rebobina, fotograma a fotograma. Detenerse a mitad de camino deja un fotograma que tiene sentido.
- La página nunca es secuestrada. La rueda, el trackpad, el tacto y las teclas mueven la página exactamente como lo hacen en cualquier otro lugar. La sección simplemente es alta.
- Las palabras siempre son legibles. Los subtítulos son texto DOM real en una tarjeta. El dibujo se sitúa en el espacio que dejan los subtítulos, y el canvas se recorta a su alrededor como una red de seguridad.
- Es un control, no solo un espectáculo. Las caras de un tambor narrativo son enlaces. Su riel, botones de flecha, teclas de flecha y arrastre con el ratón buscan desplazando la página, para que el scroll y el tambor nunca estén en desacuerdo.
- Una página inactiva no cuesta nada. Ningún requestAnimationFrame se ejecuta una vez que p se estabiliza o después de que la sección sale del viewport.
- Se respeta el movimiento reducido. Sin suavizado, ni giro intermedio del tambor, ni animación de entrada de subtítulos. Todo el contenido sigue ahí.
- Funciona desde 360 hasta 1920 px de ancho, en modo claro y oscuro, con DPR 1 y 2.
Reglas estrictas
- p proviene de la posición del scroll, nunca de un reloj. No uses
performance.now(),Date, animaciones CSS osetIntervaldentro de un carrete. Se permite el suavizado (un lerp hacia el p objetivo) porque converge al mismo fotograma. - Un carrete es una función pura
draw(ctx, t, env). No guarda estado entre fotogramas y nunca llama aMath.random(); usa unhash(i)con semilla en su lugar. El mismo (t, env) debe pintar los mismos píxeles. - Ancla con CSS, no con JS. Usa una pista alta (
position: relative; height: N×vh) que contenga un escenarioposition: sticky; top: 0; height: 100svh. Nunca establezcasposition: fixeden el escenario, nunca bloquees el body, y nunca pongasoverflow: hidden|auto|clipen un ancestro de la pista. - Nunca captures la entrada. No uses
preventDefaulten la rueda o el tacto, nioverflow: hiddenen html o body, ni scroll snapping en la pista. Solo un tambor enfocado puede manejar las teclas de flecha, y lo hace desplazando la página. - Busca desplazándote. Cada botón, elemento del riel, tecla y ancla cambia
window.scrollY. Nunca establezcas p o una rotación directamente. Usabehavior: "instant"para saltos programáticos que no deben animar, porque"auto""obedece alscroll-behavior: smoothdel CSS de la página. - El texto vive en el DOM. Los subtítulos, títulos y enlaces son HTML. El texto en canvas se limita a etiquetas decorativas como años, marcas de eje y nombres de paso.
- Mide los subtítulos cada fotograma y mantén el dibujo alejado de ellos. Cada subtítulo y elemento HUD es una zona de exclusión (
data-keep). Los carretes se colocan dentro deenv.free, y el motor recorta las zonas de exclusión con un recorte par-impar. - Nunca muestres dos subtítulos a la vez. Las ventanas de subtítulos se asientan dentro de su carrete con un espacio en cada límite, y los carretes se funden entre sí dentro de ese espacio.
- El bucle se detiene cuando está inactivo. Inicia rAF al hacer scroll o redimensionar solo mientras la sección está cerca del viewport (IntersectionObserver), y detenlo cuando p sea igual al objetivo.
- El canvas se dimensiona al tamaño del escenario con
min(devicePixelRatio, 2). Llama asetTransform(dpr, …)una vez por fotograma y límpialo cada fotograma. - Los colores provienen de propiedades personalizadas CSS. No codifiques una paleta de modo claro en un carrete. Vuelve a leer la paleta al cambiar
prefers-color-scheme. - Respeta
prefers-reduced-motion: reduce. No uses suavizado, haz que el tambor salte de cara a cara, y desactiva las animaciones de entrada de subtítulos. Los fotogramas aún siguen el scroll, porque el usuario los controla.
Orden de construcción
Hazlo en este orden. Cada paso se puede probar antes del siguiente.
- Contenido primero. Escribe los capítulos: un anticipo, un título, una o dos frases y un enlace opcional para cada uno. Elige el número de carretes (de 3 a 7) y asigna a cada uno aproximadamente de 80 a 100 svh de desplazamiento.
- Pista y escenario. Agrega la sección alta con un escenario adhesivo de 100svh dentro, y verifica que se ancle sin JS alguno (consulta layout-and-captions).
- El motor de desplazamiento. Conecta
ScrollMotion.scrubofilm(consulta engine) y registra p. Verifica que lea 0 al inicio del anclaje y 1 al final del anclaje, y que se mueva en ambas direcciones. - Subtítulos y HUD en el DOM. Colócalos según el punto de interrupción, márcalos con
data-keep, y deja que el motor controle su opacidad desde p. - Carretes, uno a la vez. Escribe
draw(ctx, t, env)dentro deenv.free(consulta drawing), y desplázalo hacia adelante y atrás antes de escribir el siguiente. - Gramática de movimiento. Agrega envolventes de entrada, sostenimiento y salida, escalonamientos, pausas y transiciones (consulta motion-grammar).
- Interactividad, si es un tambor. Agrega enlaces de caras, el riel, botones anterior y siguiente, teclas y arrastre, todo buscando mediante desplazamiento.
- Pase de rendimiento y accesibilidad. Cubre el bucle inactivo, DPR, movimiento reducido, orden de enfoque, texto de lector de pantalla y modo oscuro (consulta performance-and-a11y).
- Verifica a 360, 390, 414, 768, 1024, 1280, 1440 y 1920 con el script sin cabeza (consulta verification). No lo des por terminado hasta que todas las comprobaciones pasen.
Listas de verificación de decisiones
¿Película en canvas o transformaciones DOM/CSS?
- Muchas piezas pequeñas móviles, líneas que dibujan, partículas o gráficos → película en canvas (
ScrollMotion.film). - Un puñado de tarjetas, imágenes o paneles de texto → transformaciones DOM impulsadas por
scrub(el tambor es una). - Mallas 3D reales o shaders → esta habilidad no es suficiente. Usa WebGL, pero mantén las reglas 1 a 5 y 9 a 12.
¿Modo anclaje o vista?
- La sección debe mantener la pantalla mientras se reproduce su historia →
mode: "pin"(pista alta y escenario adhesivo). - El efecto viaja junto con el contenido, como en marquesinas o parallax →
mode: "view", sin anclaje.
¿Qué altura tiene la pista?
- Usa
n × 80–100svh + 100svhpara una película yn × 70vh + 100vhpara un tambor. Si un lector de prueba dice que va "demasiado rápido", agrega altura. Nunca la frenes capturando la rueda.
¿Dónde van los subtítulos?
- Escritorio (≥ 760 px): junto al dibujo (tarjeta izquierda o derecha) o como una tarjeta ancha en la parte inferior. Teléfonos: siempre una hoja inferior, con el dibujo encima.
- Con un encabezado de sitio fijo, establece
--sm-nav-hpara que el HUD y el tambor lo eviten.
¿Necesita el tambor?
- Usa el tambor cuando los capítulos sean cosas a las que el usuario quiera ir (páginas, productos, secciones). De lo contrario, usa solo la película.
Modos de fallo y correcciones
| Síntoma | Causa | Corrección |
|---|---|---|
| El escenario se desplaza en lugar de anclarse | Un ancestro tiene overflow: hidden/auto/clip, o el escenario no tiene top | Elimina el overflow (usa overflow-x: clip en el body si es necesario) y establece top: 0 |
| La animación solo avanza, o salta al subir el scroll | Estado acumulado entre fotogramas, o un reloj o Math.random en un carrete | Haz que el carrete sea una función pura de t; usa hash(i) para la aleatoriedad |
| El dibujo se ejecuta debajo del subtítulo | Carrete colocado en todo el escenario | Colócalo en env.free; mantén data-keep en el subtítulo; deja que el motor recorte |
| La caja del subtítulo se mide en el lugar equivocado durante una transición | Se usó getBoundingClientRect (incluye la transformación) | Mide cajas de diseño (offsetLeft/Top/Width/Height, consulta boxIn) |
| Dos subtítulos se superponen en un límite | Las ventanas de subtítulos se tocan | Desvanece los subtítulos hacia afuera en 1 − cross y hacia adentro en cross; los carretes se funden en el espacio |
| Los ventiladores giran en una página inactiva | El bucle rAF nunca se detiene | Deténlo cuando cur === target; condicionalo con IntersectionObserver |
| Canvas borroso en teléfonos / lento en 4K | Canvas al tamaño CSS, o DPR sin límite | Usa width = W × min(dpr, 2) y setTransform(dpr, 0, 0, dpr, 0, 0) |
| El tambor y la página no coinciden después de hacer clic en el riel | El control estableció la rotación directamente | Cada control llama a scrollToProgress; la rotación solo proviene de p |
| El arrastre tiembla, luego vuelve a su lugar | La página tiene scroll-behavior: smooth, por lo que scrollTo(x, y) anima | Usa scrollTo({ top, behavior: "instant" }) mientras arrastras |
| Al hacer clic en una cara después de un arrastre se abre su enlace | El clic que termina un arrastre se dispara | Suprime un clic en fase de captura después de un arrastre que se movió más de 4 px |
| Salto de 100vh en móvil cuando se oculta la barra de URL | vh en el escenario | Usa 100svh para el escenario; calcula p con la altura del propio escenario |
| El modo oscuro muestra un dibujo de modo claro | Colores codificados | Lee los tokens --sm-* mediante readPalette; vuelve a leerlos al cambiar el esquema |
| Las caras traseras del tambor se ven | Falta backface-visibility: hidden | Establécelo, con el prefijo -webkit-, en cada cara |
| Las miniaturas de las caras del tambor se dibujan al tamaño incorrecto | Se usó el rectángulo proyectado de una cara transformada en 3D | Dimensiónalos por clientWidth/clientHeight |
Archivos
- references/engine.md: p, el bucle de desplazamiento,
film(), búsqueda, el hook de React. - references/drawing.md: el contrato del carrete, configuración del canvas, rutas, apariciones, partículas y paletas.
- references/layout-and-captions.md: CSS de pista y escenario, zonas de exclusión, el rectángulo libre, el recorte, el diseño del tambor.
- references/motion-grammar.md: envolventes, escalonamientos, pausas, transiciones, ritmo y easing.
- references/performance-and-a11y.md: bucle inactivo, costo de diseño, movimiento reducido, teclado, enfoque y lectores de pantalla.
- references/verification.md: los ocho anchos, el script sin cabeza para Brave/Chrome y la verificación manual.
- assets/demo.html: independiente, sin dependencias, doble clic para ejecutar. Cinco carretes y el tambor narrativo.
- assets/scroll-motion.js: el motor (
scrub,film,drum,scrollToProgressy ayudantes), UMD. - assets/reels.js: cinco ejemplos de carretes (órbita, trayectoria, pila, onda, cierre) y
still()para miniaturas. - assets/scroll-motion.css: estilos de película y tambor, tokens de modo claro y oscuro.
- assets/ScrollFilm.jsx: el envoltorio React para la película, más el hook
useScrub. - assets/StoryDrum.jsx: el tambor narrativo React, más un
DrumPreviewauto-desplazante. assets/preview-orbit.jpg,assets/preview-path.jpg,assets/preview-close.jpg: imágenes fijas de la demo a 1440 × 900.
Comienza desde la demo: ábrela, ve el código fuente y reemplaza el contenido. El cableado al final del archivo tiene unas 60 líneas.