The hard part of a scroll-scrubbed hero is never the playback — it is the mapping and the throttle. Scroll becomes a smoothed progress value that snaps when it arrives; progress becomes a quantised frame index; the frame is only ever requested when that index changes; and the loop unsubscribes once the two agree. This component ships all four and draws its frames procedurally onto a canvas, so the technique can be read without a video file in the way.
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Scroll-Scrubbed VideoThe hard part of a scroll-scrubbed hero is never the playback — it is the mapping and the throttle. Scroll becomes a smoothed progress value that snaps when it arrives; progress becomes a quantised frame index; the frame is only ever requested when that index changes; and the loop unsubscribes once the two agree. This component ships all four and draws its frames procedurally onto a canvas, so the technique can be read without a video file in the way.Build this as a React component in my project. Match my existing conventions —file layout, styling system, and naming — rather than the ones implied below.Everything after this line is specification, not code to copy verbatim.## What it doesThe scroll-to-frame mapping a scrubbed hero actually needs: smoothing, a snap, a frame quantiser and a loop that parks itself.- Category: scroll- Runtime cost: dom- Dependencies: none beyond React## How it works1. Progress is `(hostTop - sectionTop) / (total - hostHeight)` against a manufactured range, read from live rects each frame. The range height is written on mount and on resize as well as from the loop, so a section still below the fold already occupies its full height instead of shifting the page when it arrives.2. The playhead follows with `damp(current, target, smoothing, dt)`, the per-second form of the familiar `current += (target - current) * 0.12`. `perFrameToSmoothing(0.12)` performs that conversion, so the feel is preserved and the settle no longer runs at double speed on a 144 Hz display.3. A snap threshold ends the approach. Exponential smoothing is asymptotic: without `if (|target - current| < 0.0002) current = target` the value never arrives, and any loop that waits for arrival runs forever.4. The frame index is `round(progress * (frames - 1))`, and the draw call is guarded by `index !== drawnIndex`. This is the same guard as the `Math.abs(video.currentTime - t) > 0.003` test in a real player: asking the decoder to seek to the frame it is already showing is what makes a scrubbed video judder.5. The loop parks itself. Once the value has snapped, `useRaf` is disabled and the subscription dropped; a scroll listener on the container wakes it. A settled hero therefore costs nothing, rather than 60 wasted frames a second over a still image.6. Work is gated on `useInView` with `rootMargin: 200px`, so the first frame is painted just before the section enters rather than just after — the difference between arriving on a picture and arriving on a blank box.7. The canvas backing store is sized in device pixels and the element in CSS pixels, with the ratio clamped at 2. Assigning `width`/`height` also clears the canvas, so the resize path forces a redraw by resetting the drawn index to -1.8. Smoothed progress is published as `--scrub-progress` on the section, so a scrubber bar can move continuously in CSS while the frame behind it advances in steps.## Constants — use these valuesThese are tuned. Each one has a reason; do not round them off or replace themwith values that "look cleaner".- `frames = 96` Steps the range quantises to. Around three seconds of 30 fps footage. Fewer than ~48 and the stepping is visible as stutter; many more and consecutive frames differ by less than the smoothing can resolve, so decode work is spent on frames nobody perceives.- `smoothing = perFrameToSmoothing(0.12)` The classic `0.12` per frame at 60 Hz expressed per second (~0.0004). It absorbs wheel steps without the playhead feeling detached from the gesture. The conversion is the point: the raw 0.12 would settle more than twice as fast at 144 Hz.- `SNAP = 0.0002` Progress delta below which the playhead is considered arrived. Two ten-thousandths of the range is well under one frame at any sane frame count, so snapping is invisible — and it is what allows the loop to terminate at all.- `rootMargin = 200px` Starts work just before the section enters. Enough lead time to decode and paint the first frame at typical scroll speeds, small enough that scrolling past does not warm up work that is never seen.- `viewports = 2` Scroll travel as a multiple of the scroller height. Two screens over ~96 frames is roughly one frame per 15 px of scroll, which reads as a continuous shot rather than a flipbook.- `MAX_DPR = 2` Backing-store cap. Past 2x the extra pixels are invisible on the phones that report 3x and 4x, but the per-frame fill cost scales with their square.## API```tsfunction ScrollScrubbedVideo(props: { frames?: number; draw?: (ctx: CanvasRenderingContext2D, frame: ScrubFrame) => void; viewports?: number; smoothing?: number; children?: ReactNode; scroller?: RefObject<HTMLElement | null>; className?: string }): JSX.Element```- `frames: number` — default `96` Discrete frames the scroll range quantises to.- `draw: (ctx, frame: ScrubFrame) => void` Called only when the frame index changes or after a resize. Replace with a `currentTime` write for real footage.- `viewports: number` — default `2` Scroll travel as a multiple of the scroller height.- `smoothing: number` — default `perFrameToSmoothing(0.12)` Fraction of the progress gap left after one second.- `children: ReactNode` Overlaid on the canvas — captions, a scrubber, a title.- `scroller: RefObject<HTMLElement | null>` Scroll container. Omit to auto-detect.- `className: string` Merged onto the outer section.## Reference implementation of the coreThis is the part that is hard to get right. Adapt it; do not treat the rest ofthe component as fixed.```tsxexport function ScrollScrubbedVideo({ frames = 96, draw = defaultDraw, viewports = 2, smoothing = DEFAULT_SMOOTHING, children, scroller, className,}: ScrollScrubbedVideoProps) { const section = useRef<HTMLElement>(null); const pin = useRef<HTMLDivElement>(null); const canvas = useRef<HTMLCanvasElement>(null); const current = useRef(0); const drawnIndex = useRef(-1); const lastProgress = useRef(''); const lastHeight = useRef(0); const palette = useRef<ScrubPalette | null>(null); const drawRef = useRef(draw); drawRef.current = draw; const reduced = useReducedMotion(); const size = useResizeObserver(pin); // Work starts 200 px before the section enters, so the first frame is // already painted when it arrives rather than after it. const { inView } = useInView(section, { rootMargin: '200px' }); // A self-parking loop. Once the smoothed value has reached the target there // is nothing left to compute, so the subscription is dropped entirely; a // scroll on the container wakes it. Without this, a settled hero keeps a // core busy re-deriving the same frame index sixty times a second. const [awake, setAwake] = useState(true); const render = (index: number, progress: number) => { const el = canvas.current; const ctx = el?.getContext('2d'); if (!el || !ctx || size.width === 0 || size.height === 0) return; if (!palette.current) palette.current = readPalette(el); drawnIndex.current = index; ctx.save(); const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); drawRef.current(ctx, { progress, index, frames, width: size.width, height: size.height, palette: palette.current, }); ctx.restore(); }; useEffect(() => { const el = canvas.current; if (!el || size.width === 0 || size.height === 0) return; // The backing store is sized in device pixels and the element in CSS // pixels; assigning width/height also clears it, so a redraw is forced. const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); el.width = Math.round(size.width * dpr); el.height = Math.round(size.height * dpr); palette.current = readPalette(el); drawnIndex.current = -1; setAwake(true); }, [size.width, size.height]); useEffect(() => { const sectionEl = section.current; if (!sectionEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const target = host === document.documentElement ? window : host; const wake = () => setAwake(true); target.addEventListener('scroll', wake, { passive: true }); return () => target.removeEventListener('scroll', wake); }, [scroller]); // The manufactured range is layout, so it is written on mount and on resize // rather than only from the loop — a section still below the fold must // already occupy its full height, or entering it shifts everything below. useEffect(() => { const sync = () => { const sectionEl = section.current; const pinEl = pin.current; if (!sectionEl || !pinEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const hostHeight = host === document.documentElement ? window.innerHeight : host.clientHeight; if (hostHeight === 0) return; const total = Math.round(hostHeight * (reduced ? 1 : 1 + viewports)); if (total === lastHeight.current) return; lastHeight.current = total; sectionEl.style.height = `${total}px`; pinEl.style.height = `${hostHeight}px`; setAwake(true); }; sync(); window.addEventListener('resize', sync); return () => window.removeEventListener('resize', sync); }, [reduced, viewports, scroller]); useRaf((dt) => { const sectionEl = section.current; const pinEl = pin.current; if (!sectionEl || !pinEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const hostIsRoot = host === document.documentElement; const hostTop = hostIsRoot ? 0 : host.getBoundingClientRect().top; const hostHeight = hostIsRoot ? window.innerHeight : host.clientHeight; if (hostHeight === 0) return; const total = Math.round(hostHeight * (reduced ? 1 : 1 + viewports)); if (total !== lastHeight.current) { lastHeight.current = total; sectionEl.style.height = `${total}px`; pinEl.style.height = `${hostHeight}px`; } const travel = total - hostHeight; const target = reduced ? 0.5 : travel > 0 ? clamp((hostTop - sectionEl.getBoundingClientRect().top) / travel, 0, 1) : 0; // Smooth, then snap. Without the snap the value approaches the target // asymptotically and the loop can never decide it has arrived. current.current = reduced ? target : damp(current.current, target, smoothing, dt); let settled = false; if (Math.abs(target - current.current) < SNAP) { current.current = target; settled = true; } const text = current.current.toFixed(4); if (text !== lastProgress.current) { lastProgress.current = text; sectionEl.style.setProperty('--scrub-progress', text); } // The seek throttle. A real player writes `video.currentTime` here, and // only when the frame actually changed — re-seeking to the frame already // displayed makes the decoder stall and the playhead judder. const index = clamp(Math.round(current.current * (frames - 1)), 0, frames - 1); if (index !== drawnIndex.current) render(index, current.current); if (settled) setAwake(false); }, awake && inView);```## RequirementsThese are not optional. A version of this component that skips any of them isnot finished.**Cleanup.** `useRaf` unsubscribes on unmount and again whenever the loop parks. The scroll and resize listeners are removed with their effects, `useInView` unobserves through its pool, and `useResizeObserver` disconnects. The canvas holds no timers and no decoded resources.**Reduced motion.** The manufactured range collapses to a single screen and the playhead is pinned to the middle frame with no smoothing, so the section becomes a still image with its caption rather than several screens of scroll that scrub something. The loop parks immediately and never redraws.**Frame-rate independence.** Any smoothing must be a function of delta time.`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where`smoothing` is the fraction of the gap remaining after one second.**Strict mode.** Assume effects run twice in development. Setup must beidempotent and teardown must fully undo it.## Known pitfalls — avoid these specifically- Writing `video.currentTime` on every frame. The decoder is asked to seek to the position it is already at dozens of times a second, and on Safari in particular the playhead visibly stalls and jumps. Quantise to a frame and guard the assignment.- Smoothing without a snap. The playhead approaches the target forever, the "is it settled" test never passes, and a loop written to park itself never does.- Using a per-frame smoothing constant. The scrub lags noticeably further behind the scroll at 60 Hz than at 144 Hz, and there is no single value that feels right on both.- Encoding the source with long GOPs. Seeking then lands on the nearest keyframe and decodes forward, which is why a scrub built on a normal web encode stutters no matter how good the JavaScript is — an all-intra encode is a prerequisite, not an optimisation.- Running the loop while the section is off screen. The whole point of a scrubbed hero is that it costs nothing until it is on screen; without an IntersectionObserver gate it costs a decode loop for the entire page lifetime.- Sizing the canvas in CSS only. The backing store stays at its default 300x150 and the image is stretched — the resize path must assign `width` and `height` in device pixels.- Ignoring the device pixel ratio cap. A 4x phone quadruples the fill area for pixels nobody can see, and the scrub drops to single-digit frame rates.- Leaving the manufactured range in place under reduced motion. The user gets several screens of scroll during which nothing at all changes.## Notes- No video file is shipped. `draw(ctx, frame)` renders each frame procedurally so the mapping, the quantiser and the throttle can be read on their own. A real implementation replaces the body of that call with a single assignment — `video.currentTime = frame.progress * (video.duration - 0.05)` — keeping the surrounding `index !== drawnIndex` guard exactly as it is, and draws the video element instead of the canvas.- The other things a production version needs, none of which are part of the mapping: an all-intra encode (`-g 1`), several responsive encodes selected by width with the playhead position preserved across the swap, and a decode prime on Safari — `play()` followed immediately by `pause()` — because a video that has never played will not seek.- The `frames` value should match the source: with a video, use `duration * fps` so one index step is one real frame.- The default draw reads its colours from the `--k-*` custom properties on the canvas, so the placeholder footage follows the theme.## Definition of doneScrolling advances the frame counter smoothly and reverses exactly. Stopping settles within a few frames, after which a performance profile shows no rAF activity until the next scroll. Scrolling the section off screen stops all work. Resizing redraws at the correct resolution with no stretching. The settle takes the same wall-clock time at 60 and 144 Hz. With reduced motion on, one static frame is shown and the section is one screen tall.When you are finished, tell me which of the requirements above you implementedand where, so I can check them.
'use client';import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';import { useInView } from '@/hooks/use-in-view';import { useRaf } from '@/hooks/use-raf';import { useReducedMotion } from '@/hooks/use-reduced-motion';import { useResizeObserver } from '@/hooks/use-resize-observer';import { clamp, damp, perFrameToSmoothing } from '@/lib/damp';import { cn } from '@/lib/utils';/* ========================================================================== Scroll-Scrubbed Video -------------------------------------------------------------------------- The hard part of a scroll-scrubbed hero is not playing the video. It is the mapping and the throttle: - scroll position -> a smoothed, snapping progress value - progress -> a frame, quantised, so the same frame is never requested twice in a row - a loop that parks itself once the two agree, instead of running forever at 60 Hz over a still image Get those three right and the video part is one line. Get them wrong and no amount of encoding work saves it: the playhead judders, the decoder is asked to seek to a position it is already at several times a second, and the tab burns a core while nothing on screen changes. So this component ships the mapping, and draws its frames procedurally onto a canvas rather than shipping a video file. `draw(ctx, frame)` is called only on the frames that actually change. In a real implementation that call is replaced by one assignment: video.currentTime = frame.progress * (video.duration - 0.05); guarded by the same "has the frame changed" test used here. ========================================================================== */export interface ScrubPalette { bg: string; surface: string; fg: string; muted: string; line: string; accent: string;}export interface ScrubFrame { /** Smoothed scroll progress, 0-1. */ progress: number; /** `progress` quantised to `frames` steps — the thing that gates a redraw. */ index: number; frames: number; /** CSS pixels. The context is already scaled by device pixel ratio. */ width: number; height: number; palette: ScrubPalette;}export type ScrubDraw = (ctx: CanvasRenderingContext2D, frame: ScrubFrame) => void;export interface ScrollScrubbedVideoProps { /** How many discrete frames the scroll range quantises to. */ frames?: number; /** Called only when the frame index changes, or after a resize. */ draw?: ScrubDraw; /** Scroll travel as a multiple of the scroller height. */ viewports?: number; /** Fraction of the progress gap left after one second. */ smoothing?: number; /** Overlaid on the canvas — captions, a scrubber, a title. */ children?: ReactNode; /** Scroll container. Omit to auto-detect the nearest scrollable ancestor. */ scroller?: RefObject<HTMLElement | null>; className?: string;}/** Below this, the playhead is treated as having arrived. See `SNAP` in the meta. */const SNAP = 0.0002;/** Lenis-style `0.12` per frame at 60 Hz, converted to a per-second constant. */const DEFAULT_SMOOTHING = perFrameToSmoothing(0.12);const MAX_DPR = 2;const TAU = Math.PI * 2;const SCROLLABLE = /(auto|scroll|overlay)/;function nearestScroller(el: HTMLElement): HTMLElement { let node = el.parentElement; while (node) { if (SCROLLABLE.test(getComputedStyle(node).overflowY)) return node; node = node.parentElement; } return document.documentElement;}function readPalette(el: HTMLElement): ScrubPalette { const style = getComputedStyle(el); const get = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback; return { bg: get('--k-bg', '#0a0a0a'), surface: get('--k-surface', '#1a1a1a'), fg: get('--k-fg', '#f2f2f2'), muted: get('--k-fg-muted', '#9a9a9a'), line: get('--k-line', 'rgba(255,255,255,0.08)'), accent: get('--k-accent', '#6aa6ff'), };}/** * Stand-in for the decoded frame: a camera push over three parallax ridges * with the light rising through the shot. Deterministic in `progress`, so * scrubbing backwards reproduces the frame exactly, which is the property a * real all-intra encode is chosen for. */function defaultDraw(ctx: CanvasRenderingContext2D, frame: ScrubFrame): void { const { progress, width, height, palette, index, frames } = frame; ctx.fillStyle = palette.bg; ctx.fillRect(0, 0, width, height); const disc = Math.min(width, height) * 0.17; ctx.globalAlpha = 0.85; ctx.fillStyle = palette.accent; ctx.beginPath(); ctx.arc(width * 0.5, height * (0.82 - progress * 0.5), disc, 0, TAU); ctx.fill(); for (let layer = 0; layer < 3; layer += 1) { const depth = (layer + 1) / 3; const shift = progress * width * 0.75 * depth; const baseline = height * (0.56 + 0.13 * layer); const amp = height * 0.055 * (1 + layer * 0.5); ctx.beginPath(); ctx.moveTo(0, height); for (let x = 0; x <= width; x += 6) { const t = (x + shift) / (width * 0.55); const y = baseline + Math.sin(t * TAU + layer) * amp + Math.sin(t * TAU * 2.3 + layer * 2) * amp * 0.3; ctx.lineTo(x, y); } ctx.lineTo(width, height); ctx.closePath(); ctx.globalAlpha = 0.55 + layer * 0.15; ctx.fillStyle = layer === 2 ? palette.bg : palette.surface; ctx.fill(); } // A visible frame counter: the point of the technique is that this number // changes in steps while the scroll moves continuously. ctx.globalAlpha = 1; ctx.fillStyle = palette.muted; ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'; ctx.fillText(`${String(index + 1).padStart(3, '0')} / ${frames}`, 16, height - 16);}// #region criticalexport function ScrollScrubbedVideo({ frames = 96, draw = defaultDraw, viewports = 2, smoothing = DEFAULT_SMOOTHING, children, scroller, className,}: ScrollScrubbedVideoProps) { const section = useRef<HTMLElement>(null); const pin = useRef<HTMLDivElement>(null); const canvas = useRef<HTMLCanvasElement>(null); const current = useRef(0); const drawnIndex = useRef(-1); const lastProgress = useRef(''); const lastHeight = useRef(0); const palette = useRef<ScrubPalette | null>(null); const drawRef = useRef(draw); drawRef.current = draw; const reduced = useReducedMotion(); const size = useResizeObserver(pin); // Work starts 200 px before the section enters, so the first frame is // already painted when it arrives rather than after it. const { inView } = useInView(section, { rootMargin: '200px' }); // A self-parking loop. Once the smoothed value has reached the target there // is nothing left to compute, so the subscription is dropped entirely; a // scroll on the container wakes it. Without this, a settled hero keeps a // core busy re-deriving the same frame index sixty times a second. const [awake, setAwake] = useState(true); const render = (index: number, progress: number) => { const el = canvas.current; const ctx = el?.getContext('2d'); if (!el || !ctx || size.width === 0 || size.height === 0) return; if (!palette.current) palette.current = readPalette(el); drawnIndex.current = index; ctx.save(); const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); drawRef.current(ctx, { progress, index, frames, width: size.width, height: size.height, palette: palette.current, }); ctx.restore(); }; useEffect(() => { const el = canvas.current; if (!el || size.width === 0 || size.height === 0) return; // The backing store is sized in device pixels and the element in CSS // pixels; assigning width/height also clears it, so a redraw is forced. const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); el.width = Math.round(size.width * dpr); el.height = Math.round(size.height * dpr); palette.current = readPalette(el); drawnIndex.current = -1; setAwake(true); }, [size.width, size.height]); useEffect(() => { const sectionEl = section.current; if (!sectionEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const target = host === document.documentElement ? window : host; const wake = () => setAwake(true); target.addEventListener('scroll', wake, { passive: true }); return () => target.removeEventListener('scroll', wake); }, [scroller]); // The manufactured range is layout, so it is written on mount and on resize // rather than only from the loop — a section still below the fold must // already occupy its full height, or entering it shifts everything below. useEffect(() => { const sync = () => { const sectionEl = section.current; const pinEl = pin.current; if (!sectionEl || !pinEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const hostHeight = host === document.documentElement ? window.innerHeight : host.clientHeight; if (hostHeight === 0) return; const total = Math.round(hostHeight * (reduced ? 1 : 1 + viewports)); if (total === lastHeight.current) return; lastHeight.current = total; sectionEl.style.height = `${total}px`; pinEl.style.height = `${hostHeight}px`; setAwake(true); }; sync(); window.addEventListener('resize', sync); return () => window.removeEventListener('resize', sync); }, [reduced, viewports, scroller]); useRaf((dt) => { const sectionEl = section.current; const pinEl = pin.current; if (!sectionEl || !pinEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const hostIsRoot = host === document.documentElement; const hostTop = hostIsRoot ? 0 : host.getBoundingClientRect().top; const hostHeight = hostIsRoot ? window.innerHeight : host.clientHeight; if (hostHeight === 0) return; const total = Math.round(hostHeight * (reduced ? 1 : 1 + viewports)); if (total !== lastHeight.current) { lastHeight.current = total; sectionEl.style.height = `${total}px`; pinEl.style.height = `${hostHeight}px`; } const travel = total - hostHeight; const target = reduced ? 0.5 : travel > 0 ? clamp((hostTop - sectionEl.getBoundingClientRect().top) / travel, 0, 1) : 0; // Smooth, then snap. Without the snap the value approaches the target // asymptotically and the loop can never decide it has arrived. current.current = reduced ? target : damp(current.current, target, smoothing, dt); let settled = false; if (Math.abs(target - current.current) < SNAP) { current.current = target; settled = true; } const text = current.current.toFixed(4); if (text !== lastProgress.current) { lastProgress.current = text; sectionEl.style.setProperty('--scrub-progress', text); } // The seek throttle. A real player writes `video.currentTime` here, and // only when the frame actually changed — re-seeking to the frame already // displayed makes the decoder stall and the playhead judder. const index = clamp(Math.round(current.current * (frames - 1)), 0, frames - 1); if (index !== drawnIndex.current) render(index, current.current); if (settled) setAwake(false); }, awake && inView); // #endregion critical return ( <section ref={section} className={cn('relative w-full', className)}> <div ref={pin} className="sticky top-0 w-full overflow-hidden"> <canvas ref={canvas} className="block h-full w-full" aria-hidden /> {children} </div> </section> );}
/* ========================================================================== Frame-rate-independent interpolation. -------------------------------------------------------------------------- The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice as fast on a 120 Hz display as on a 60 Hz one, so the same code feels different on different hardware and visibly speeds up when the browser drops frames. `damp` fixes it by raising the retention factor to the power of elapsed time, which is the exact continuous form of the same curve. ========================================================================== *//** * Fraction of the remaining distance to close this frame. * * @param smoothing Fraction of the gap that REMAINS after one second. * Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft, * 0.5 ≈ slow drift. * @param dt Seconds elapsed since the previous frame. */export function dampFactor(smoothing: number, dt: number): number { return 1 - Math.pow(smoothing, dt);}/** Move `current` toward `target` at a rate independent of frame rate. */export function damp( current: number, target: number, smoothing: number, dt: number,): number { return current + (target - current) * dampFactor(smoothing, dt);}/** * The per-frame lerp constant an author measured at 60fps, converted into the * `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame" * be ported without re-tuning by eye: * * damp(x, t, perFrameToSmoothing(0.1), dt) */export function perFrameToSmoothing(alpha: number, baseFps = 60): number { return Math.pow(1 - alpha, baseFps);}export function lerp(a: number, b: number, t: number): number { return a + (b - a) * t;}export function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v;}export function inverseLerp(a: number, b: number, v: number): number { return a === b ? 0 : (v - a) / (b - a);}export function mapRange( v: number, inMin: number, inMax: number, outMin: number, outMax: number, doClamp = true,): number { const t = inverseLerp(inMin, inMax, v); const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t); return r;}/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */export function smoothstep(edge0: number, edge1: number, x: number): number { const t = clamp(inverseLerp(edge0, edge1, x), 0, 1); return t * t * (3 - 2 * t);}/** * Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at * distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and * no branches, which is why carousels that use it feel analogue rather than * stepped. */export function gaussian(distance: number, falloff = 1): number { const d = distance * falloff; return Math.exp(-(d * d));}
'use client';import { useEffect, useRef, useState, type RefObject } from 'react';/* ========================================================================== IntersectionObserver, sharing one observer per option set. -------------------------------------------------------------------------- A hundred cards each constructing their own observer is a hundred observers the compositor has to service. Keying a shared observer by its options collapses that to one per distinct configuration — in practice, two or three for the whole site. ========================================================================== */export interface InViewState { inView: boolean; /** 0-1. The preview slot allocator ranks candidates by this. */ ratio: number;}export interface UseInViewOptions { root?: Element | null; rootMargin?: string; /** Single number or list, same meaning as the native option. */ threshold?: number | number[]; /** Stop observing after the first time it becomes visible. */ once?: boolean;}type Entry = { observer: IntersectionObserver; handlers: Map<Element, (e: IntersectionObserverEntry) => void>;};const pools = new Map<string, Entry>();function poolFor(options: UseInViewOptions): Entry { const key = JSON.stringify({ rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0, }); const existing = pools.get(key); if (existing) return existing; const handlers = new Map<Element, (e: IntersectionObserverEntry) => void>(); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) handlers.get(entry.target)?.(entry); }, { root: options.root ?? null, rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0, }, ); const created: Entry = { observer, handlers }; pools.set(key, created); return created;}export function useInView( ref: RefObject<Element | null>, options: UseInViewOptions = {},): InViewState { const [state, setState] = useState<InViewState>({ inView: false, ratio: 0 }); const { root = null, rootMargin = '0px', once = false } = options; const thresholdKey = JSON.stringify(options.threshold ?? 0); const done = useRef(false); useEffect(() => { const el = ref.current; if (!el || typeof IntersectionObserver === 'undefined') return; if (once && done.current) return; const pool = poolFor({ root, rootMargin, threshold: JSON.parse(thresholdKey) as number | number[], }); const handle = (entry: IntersectionObserverEntry) => { setState({ inView: entry.isIntersecting, ratio: entry.intersectionRatio }); if (once && entry.isIntersecting) { done.current = true; pool.handlers.delete(el); pool.observer.unobserve(el); } }; pool.handlers.set(el, handle); pool.observer.observe(el); return () => { pool.handlers.delete(el); pool.observer.unobserve(el); }; }, [ref, root, rootMargin, thresholdKey, once]); return state;}
'use client';import { useEffect, useRef } from 'react';/* ========================================================================== One shared requestAnimationFrame loop. -------------------------------------------------------------------------- Forty components each calling requestAnimationFrame means forty callbacks the browser schedules separately and forty chances to read layout after a write. One loop, one ordered pass, one place to clamp delta time. Delta is clamped to 100 ms. Without that, returning to a backgrounded tab delivers a multi-second dt and every physics integration in the page explodes on the first frame back. ========================================================================== */export type RafCallback = (dt: number, elapsed: number) => void;const callbacks = new Set<RafCallback>();let frame = 0;let last = 0;let start = 0;const MAX_DT = 0.1;const DEFAULT_DT = 1 / 60;function tick(now: number) { frame = requestAnimationFrame(tick); const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT); last = now; const elapsed = (now - start) / 1000; // Copy: a callback may unsubscribe itself (or another) mid-iteration. for (const cb of Array.from(callbacks)) { if (callbacks.has(cb)) cb(dt, elapsed); }}function subscribe(cb: RafCallback) { callbacks.add(cb); if (frame === 0) { start = performance.now(); last = 0; frame = requestAnimationFrame(tick); } return () => { callbacks.delete(cb); if (callbacks.size === 0 && frame !== 0) { cancelAnimationFrame(frame); frame = 0; last = 0; } };}/** * Run `cb` every frame while `enabled`. * * The callback is held in a ref, so it does not need to be memoised and the * subscription is never torn down just because the closure changed identity. */export function useRaf(cb: RafCallback, enabled = true): void { const ref = useRef(cb); ref.current = cb; useEffect(() => { if (!enabled) return; return subscribe((dt, elapsed) => ref.current(dt, elapsed)); }, [enabled]);}
'use client';import { useSyncExternalStore } from 'react';/* ========================================================================== prefers-reduced-motion, SSR-safe. -------------------------------------------------------------------------- useSyncExternalStore rather than useState + useEffect: the server snapshot is an explicit `false`, so the markup React renders on the server and the markup it hydrates with agree, and the real value lands in the same commit as hydration instead of one frame later. A useEffect version flashes the animated variant before removing it — the exact thing the user asked not to see. Reduced motion is not "duration: 0". For most components here it means a different render: the final state, a static poster, or a paused frame. ========================================================================== */const QUERY = '(prefers-reduced-motion: reduce)';function subscribe(onChange: () => void) { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(QUERY); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange);}function getSnapshot() { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(QUERY).matches;}function getServerSnapshot() { return false;}export function useReducedMotion(): boolean { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}/** Generic media-query hook, same SSR guarantees. */export function useMediaQuery(query: string): boolean { return useSyncExternalStore( (onChange) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(query); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, () => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(query).matches; }, () => false, );}
'use client';import { useEffect, useState, type RefObject } from 'react';/* ========================================================================== Element size, from one shared ResizeObserver. -------------------------------------------------------------------------- Sized from the element rather than the window because a preview in a grid cell resizes when the grid reflows, not when the viewport does. Reading `window.innerWidth` is the reason so many canvas components are correct on load and wrong after a sidebar opens. ========================================================================== */export interface Size { width: number; height: number;}type Handler = (size: Size) => void;let observer: ResizeObserver | null = null;const handlers = new Map<Element, Handler>();function ensureObserver(): ResizeObserver | null { if (typeof ResizeObserver === 'undefined') return null; observer ??= new ResizeObserver((entries) => { for (const entry of entries) { const box = entry.contentBoxSize?.[0]; const size: Size = box ? { width: box.inlineSize, height: box.blockSize } : { width: entry.contentRect.width, height: entry.contentRect.height }; handlers.get(entry.target)?.(size); } }); return observer;}export function useResizeObserver(ref: RefObject<Element | null>): Size { const [size, setSize] = useState<Size>({ width: 0, height: 0 }); useEffect(() => { const el = ref.current; const ro = ensureObserver(); if (!el || !ro) return; // Seed synchronously: waiting for the first observer callback leaves one // frame where a canvas is sized 0x0, which some drivers log as an error. const rect = el.getBoundingClientRect(); setSize({ width: rect.width, height: rect.height }); handlers.set(el, (next) => setSize((prev) => prev.width === next.width && prev.height === next.height ? prev : next, ), ); ro.observe(el); return () => { handlers.delete(el); ro.unobserve(el); }; }, [ref]); return size;}
05
API
Every parameter, its type and what happens when you change it.
Called only when the frame index changes or after a resize. Replace with a currentTime write for real footage.
viewports
number
= 2
Scroll travel as a multiple of the scroller height.
smoothing
number
= perFrameToSmoothing(0.12)
Fraction of the progress gap left after one second.
children
ReactNode
—
Overlaid on the canvas — captions, a scrubber, a title.
scroller
RefObject<HTMLElement | null>
—
Scroll container. Omit to auto-detect.
className
string
—
Merged onto the outer section.
06
How it works
The technique in order, not the code in order.
01
Progress is (hostTop - sectionTop) / (total - hostHeight) against a manufactured range, read from live rects each frame. The range height is written on mount and on resize as well as from the loop, so a section still below the fold already occupies its full height instead of shifting the page when it arrives.
02
The playhead follows with damp(current, target, smoothing, dt), the per-second form of the familiar current += (target - current) * 0.12. perFrameToSmoothing(0.12) performs that conversion, so the feel is preserved and the settle no longer runs at double speed on a 144 Hz display.
03
A snap threshold ends the approach. Exponential smoothing is asymptotic: without if (|target - current| < 0.0002) current = target the value never arrives, and any loop that waits for arrival runs forever.
04
The frame index is round(progress * (frames - 1)), and the draw call is guarded by index !== drawnIndex. This is the same guard as the Math.abs(video.currentTime - t) > 0.003 test in a real player: asking the decoder to seek to the frame it is already showing is what makes a scrubbed video judder.
05
The loop parks itself. Once the value has snapped, useRaf is disabled and the subscription dropped; a scroll listener on the container wakes it. A settled hero therefore costs nothing, rather than 60 wasted frames a second over a still image.
06
Work is gated on useInView with rootMargin: 200px, so the first frame is painted just before the section enters rather than just after — the difference between arriving on a picture and arriving on a blank box.
07
The canvas backing store is sized in device pixels and the element in CSS pixels, with the ratio clamped at 2. Assigning width/height also clears the canvas, so the resize path forces a redraw by resetting the drawn index to -1.
08
Smoothed progress is published as --scrub-progress on the section, so a scrubber bar can move continuously in CSS while the frame behind it advances in steps.
Load-bearing constants
Load-bearing constants
Constant
Value
Why this number
frames
= 96
Steps the range quantises to. Around three seconds of 30 fps footage. Fewer than ~48 and the stepping is visible as stutter; many more and consecutive frames differ by less than the smoothing can resolve, so decode work is spent on frames nobody perceives.
smoothing
= perFrameToSmoothing(0.12)
The classic 0.12 per frame at 60 Hz expressed per second (~0.0004). It absorbs wheel steps without the playhead feeling detached from the gesture. The conversion is the point: the raw 0.12 would settle more than twice as fast at 144 Hz.
SNAP
= 0.0002
Progress delta below which the playhead is considered arrived. Two ten-thousandths of the range is well under one frame at any sane frame count, so snapping is invisible — and it is what allows the loop to terminate at all.
rootMargin
= 200px
Starts work just before the section enters. Enough lead time to decode and paint the first frame at typical scroll speeds, small enough that scrolling past does not warm up work that is never seen.
viewports
= 2
Scroll travel as a multiple of the scroller height. Two screens over ~96 frames is roughly one frame per 15 px of scroll, which reads as a continuous shot rather than a flipbook.
MAX_DPR
= 2
Backing-store cap. Past 2x the extra pixels are invisible on the phones that report 3x and 4x, but the per-frame fill cost scales with their square.
07
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The manufactured range collapses to a single screen and the playhead is pinned to the middle frame with no smoothing, so the section becomes a still image with its caption rather than several screens of scroll that scrub something. The loop parks immediately and never redraws.
Cleanup
useRaf unsubscribes on unmount and again whenever the loop parks. The scroll and resize listeners are removed with their effects, useInView unobserves through its pool, and useResizeObserver disconnects. The canvas holds no timers and no decoded resources.
Definition of done
Scrolling advances the frame counter smoothly and reverses exactly. Stopping settles within a few frames, after which a performance profile shows no rAF activity until the next scroll. Scrolling the section off screen stops all work. Resizing redraws at the correct resolution with no stretching. The settle takes the same wall-clock time at 60 and 144 Hz. With reduced motion on, one static frame is shown and the section is one screen tall.
Pitfalls
Writing video.currentTime on every frame. The decoder is asked to seek to the position it is already at dozens of times a second, and on Safari in particular the playhead visibly stalls and jumps. Quantise to a frame and guard the assignment.
Smoothing without a snap. The playhead approaches the target forever, the "is it settled" test never passes, and a loop written to park itself never does.
Using a per-frame smoothing constant. The scrub lags noticeably further behind the scroll at 60 Hz than at 144 Hz, and there is no single value that feels right on both.
Encoding the source with long GOPs. Seeking then lands on the nearest keyframe and decodes forward, which is why a scrub built on a normal web encode stutters no matter how good the JavaScript is — an all-intra encode is a prerequisite, not an optimisation.
Running the loop while the section is off screen. The whole point of a scrubbed hero is that it costs nothing until it is on screen; without an IntersectionObserver gate it costs a decode loop for the entire page lifetime.
Sizing the canvas in CSS only. The backing store stays at its default 300x150 and the image is stretched — the resize path must assign width and height in device pixels.
Ignoring the device pixel ratio cap. A 4x phone quadruples the fill area for pixels nobody can see, and the scrub drops to single-digit frame rates.
Leaving the manufactured range in place under reduced motion. The user gets several screens of scroll during which nothing at all changes.
Notes
No video file is shipped. draw(ctx, frame) renders each frame procedurally so the mapping, the quantiser and the throttle can be read on their own. A real implementation replaces the body of that call with a single assignment — video.currentTime = frame.progress * (video.duration - 0.05) — keeping the surrounding index !== drawnIndex guard exactly as it is, and draws the video element instead of the canvas.
The other things a production version needs, none of which are part of the mapping: an all-intra encode (-g 1), several responsive encodes selected by width with the playhead position preserved across the swap, and a decode prime on Safari — play() followed immediately by pause() — because a video that has never played will not seek.
The frames value should match the source: with a video, use duration * fps so one index step is one real frame.
The default draw reads its colours from the --k-* custom properties on the canvas, so the placeholder footage follows the theme.