| name | scroll-scrub-canvas |
| description | Patrón Apple AirPods Pro / MacBook marketing - hero con video renderizado como secuencia de frames JPG/WebP en canvas, scrubbed por scroll position. NO `<video>` tag (degrada en mobile + sin control fino del scrub). El componente preload todos los frames + IntersectionObserver para pause off-screen + dpr-aware resize + prefers-reduced-motion fallback. Trigger scroll-scrub, scrub video, frame sequence canvas, apple airpods landing, hero scrubbed. |
| when_to_use | Hero cinematográfico tipo Apple/Stripe/Sony Bravia.
Video corto (5-15s ideal) que el cliente quiere "scrubbeable" con scroll.
Necesitás control fino del playback (NO basta `<video>` autoplay).
Cliente ok con extraer frames pre-deploy (ffmpeg pipeline).
|
| when_NOT_to_use | Video largo (>20s) — los frames pesarían demasiado (>20MB Vercel hobby cap).
Cliente quiere audio en el video — los frames no tienen audio (separar a `<audio>` aparte).
Mobile-only — preload de 240+ frames mata datos. Considerar versión reducida o `<video>`.
|
Scroll-Scrub Canvas
Pattern Apple AirPods Pro: hero como canvas frame-indexed, scrubbed por scroll position.
Por qué canvas y no <video>
Problema con <video> | Solución con canvas |
|---|
currentTime setter es laggy en mobile | Frames preloaded, swap O(1) |
| Sin control fino de qué frame mostrar | Index directo al array |
| Mobile autoplay restrictions | No autoplay needed, draw-on-scroll |
| Codec issues cross-browser | JPG/WebP universal |
| LCP impredecible | Frame_0001 priority preload |
Frame extraction pipeline
Step 1: extraer frames con ffmpeg
mkdir -p input public/frames
ffmpeg -i input/source.mp4 \
-vf "fps=30,scale='min(1920,iw)':'-2':flags=lanczos" \
-q:v 3 \
public/frames/frame_%04d.jpg
ls public/frames | wc -l
Step 2 (opcional): convert a WebP (~40% más chico)
for f in public/frames/*.jpg; do
cwebp -q 82 "$f" -o "${f%.jpg}.webp" && rm "$f"
done
Step 3: gitignore source
# .gitignore
input/
Reglas
- Filenames: zero-padded 4-digit (
frame_0001.jpg … frame_NNNN.jpg)
- FPS típico 30 para 5-10s video → 150-300 frames
- Total payload < 20MB (Vercel hobby cap es 25MB)
- Si excede: usar WebP, reducir resolución, o subir a CDN external
Component completo (TypeScript + React)
import { useEffect, useRef } from "react";
export type ScrubSequenceProps = {
framesPath: string;
frameCount: number;
ext?: "jpg" | "webp";
className?: string;
scrollTargetRef: React.RefObject<HTMLElement>;
};
const pad4 = (n: number) => String(n).padStart(4, "0");
export function ScrubSequence({
framesPath,
frameCount,
ext = "jpg",
className,
scrollTargetRef,
}: ScrubSequenceProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imagesRef = useRef<HTMLImageElement[]>([]);
const rafRef = useRef<number | null>(null);
const visible = useRef(true);
const prefersReduced = useRef(
!== &&
.().
);
( {
: [] = [];
urls = .(
{ : frameCount },
);
first = ();
first. = urls[];
first. = ;
imgs[] = first;
urls.().( {
img = ();
img. = src;
imgs[i + ] = img;
});
imagesRef. = imgs;
}, [framesPath, frameCount, ext]);
( {
canvas = canvasRef.;
(!canvas) ;
= () => {
dpr = .(. || , );
canvas. = . * dpr;
canvas. = . * dpr;
canvas.. = ;
canvas.. = ;
(());
};
();
.(, resize);
.(, resize);
}, []);
( {
el = scrollTargetRef.;
(!el) ;
io = (
{ visible. = entry.; },
{ : }
);
io.(el);
io.();
}, [scrollTargetRef]);
( {
= () => {
(visible. && !prefersReduced.) {
(());
}
rafRef. = (tick);
};
();
{ (rafRef.) (rafRef.); };
}, []);
( {
(prefersReduced.) {
mid = .(frameCount / );
img = imagesRef.[mid];
(img?.) (img);
img?.(, (img), { : });
}
}, [frameCount]);
= () => {
el = scrollTargetRef.;
(!el) ;
rect = el.();
total = el. - .;
progress = total > ? .(, .(, -rect. / total)) : ;
.(frameCount - , .(progress * (frameCount - )));
};
= () => {
img = imagesRef.[idx];
(img && img. && img. > ) (img);
};
= () => {
canvas = canvasRef.;
(!canvas) ;
ctx = canvas.();
(!ctx) ;
cw = canvas., ch = canvas.;
iw = img., ih = img.;
scale = .(cw / iw, ch / ih);
dw = iw * scale, dh = ih * scale;
dx = (cw - dw) / , dy = (ch - dh) / ;
ctx.(, , cw, ch);
ctx.(img, dx, dy, dw, dh);
};
(
);
}
Hero wrapper pattern
export function Hero({ scrollRef }: { scrollRef: React.RefObject<HTMLElement> }) {
return (
<section ref={scrollRef} className="relative h-[250vh] bg-background">
<div className="sticky top-0 h-screen w-full overflow-hidden">
<ScrubSequence
framesPath="/frames"
frameCount={240}
ext="jpg"
scrollTargetRef={scrollRef}
className="absolute inset-0 w-full h-full z-0"
/>
{/* sr-only describe el video para a11y */}
<p className="sr-only">Video showing [describir el contenido del video aquí].</p>
{/* Vignette */}
<div className="absolute inset-0 z-[1] bg-[radial-gradient(120%_80%_at_50%_60%,transparent_40%,rgba(0,0,0,0.55)_100%)]" />
{/* Bottom fade */}
<div className="absolute bottom-0 inset-x-0 h-[40vh] z-[2] bg-gradient-to-t from-background to-transparent" />
{/* Content */}
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center text-center px-6">
{/* Headline + CTAs */}
</div>
);
}
Reglas no-negociables
- Sección outer =
h-[250vh] (o más si frames > 240). Da scroll para playback completo.
- Inner
sticky top-0 h-screen — pin canvas mientras el outer scrollea.
- Canvas absoluto inset-0 z-0 dentro del sticky.
- Content
z-10, encima del canvas.
aria-hidden="true" en canvas + <p className="sr-only"> describe video (a11y).
- Preload
frame_0001 con fetchpriority="high" + <link rel="preload" as="image" href="/frames/frame_0001.jpg" fetchpriority="high"> en index.html.
- dpr cap a 2 para no matar mobile.
- IntersectionObserver pause cuando off-screen (ahorra batería + CPU).
prefers-reduced-motion → render frame del medio (estático).
- NO usar
useScroll de Framer Motion — es más lento que rAF directo para este caso.
Anti-patrones
<video src="x.mp4" /> con currentTime setter en onScroll → laggy mobile.
- Loop infinito
setInterval para draw → no respeta scroll, drift visible.
- Sin
requestAnimationFrame → scroll se siente cortado.
- Sin pause off-screen → batería mobile drena.
- Sin priority preload frame 1 → LCP > 4s.
Sizing del scroll range
| FRAME_COUNT | Section height óptimo |
|---|
| 90 (3s @ 30fps) | h-[150vh] |
| 150 (5s) | h-[200vh] |
| 240 (8s) | h-[250vh] |
| 300 (10s) | h-[300vh] |
| 450 (15s) | h-[400vh] |
Si el scroll va MUY rápido y el video se siente "saltado": aumentar height. Si va MUY lento y aburre: reducir.
Recursos