/** * ScrollScene — a section that pins itself while a stack of full-bleed images * plays behind text that swaps in place. * * The root is the scroll track; the caller supplies the markup and tags it: * .scroll-scene-stage one sticky, viewport-tall stage (required) * .scroll-scene-layer N background images inside it, painted in DOM order * .scroll-scene-step N text blocks, stacked on top of each other * .scroll-scene-tick N optional progress ticks * * The track's height is stage height + N * stepVh viewports, so the stage stays * pinned for exactly N steps. Progress is read from the track's rect — the track * is never transformed, so a frame's reading can't be polluted by the previous * frame's output. * * Per step, in track progress t = progress * N: * layer i slides up over the whole of step i-1, linearly, so the image moves * with the wheel the entire time and never sits still mid-step. The layer it * covers eases back by layerScale so the stack recedes into the background; * the heading swaps at textSwapAt, out then in, one at a time. * * Position is taken straight from the scroll — `smoothing` at 1 means no chasing * and no catch-up after the wheel stops. Lower it only if a scene wants drift. */ const ScrollScene = ({ children, className = '', stepVh = 0.55, // viewport heights of scrolling per step imageTransition = 1, // share of a step the image slide takes (1 = always moving) textTransition = 0.1, // share of a step a heading takes to fade textSwapAt = 0.5, // point in the step where the heading hands over layerScale = 0.05, // how far a covered image eases back textShift = 12, // px the text travels while crossfading smoothing = 1 // 1 = track the scroll exactly; < 1 lerps towards it }) => { const rootRef = React.useRef(null); const rafRef = React.useRef(0); const lastFrameRef = React.useRef(-1); React.useLayoutEffect(() => { const root = rootRef.current; if (!root) return; const stage = root.querySelector('.scroll-scene-stage'); const layers = Array.from(root.querySelectorAll('.scroll-scene-layer')); const steps = Array.from(root.querySelectorAll('.scroll-scene-step')); const ticks = Array.from(root.querySelectorAll('.scroll-scene-tick')); const n = layers.length; if (!stage || !n) return; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const clamp = (v, min, max) => (v < min ? min : v > max ? max : v); let travel = 1; let current = 0; // scene position, in steps let settled = false; const painted = []; // last transform written per layer, so we skip no-ops // Decode the images up front. Without this the first slide of each one // lands on a decode, which is exactly where a hitch would be visible. layers.forEach(layer => { const url = (layer.style.backgroundImage.match(/url\(["']?(.*?)["']?\)/) || [])[1]; if (!url) return; const img = new Image(); img.src = url; if (img.decode) img.decode().catch(() => {}); }); const targetSteps = () => clamp(-root.getBoundingClientRect().top / travel, 0, 1) * n; const layout = () => { travel = Math.max(1, Math.round(n * stepVh * window.innerHeight)); root.style.height = `${stage.offsetHeight + travel}px`; layers.forEach((layer, i) => { layer.style.zIndex = String(i + 1); }); current = targetSteps(); // adopt the position, never animate into it settled = false; }; const render = t => { for (let i = 0; i < n; i++) { // How far this image has slid up over the one before it, and how far the // next one has slid over this one. const p = i === 0 ? 1 : clamp((t - i + imageTransition) / imageTransition, 0, 1); const covered = i + 1 < n ? clamp((t - (i + 1) + imageTransition) / imageTransition, 0, 1) : 0; // Linear, deliberately: the image travels in step with the wheel, so it // can't look like it is easing in late or coasting on afterwards. const slide = reduceMotion ? (p >= 1 ? 0 : 100) : (1 - p) * 100; const scale = reduceMotion ? 1 : 1 - layerScale * covered; const transform = `translate3d(0, ${slide.toFixed(2)}%, 0) scale(${scale.toFixed(4)})`; // Parked layers hold the same transform for most of the scene; skipping // the write keeps the compositor off work it doesn't need. if (painted[i] !== transform) { layers[i].style.transform = transform; painted[i] = transform; } } for (let i = 0; i < steps.length; i++) { // The heading hands over partway through the slide (textSwapAt), out // first and then in, so two lines of large type never overlap and the // copy changes while its image is arriving rather than after it lands. const fadeIn = clamp((t - (i - textSwapAt)) / textTransition, 0, 1); // The last step holds — the section is on its way out by then. const fadeOut = i === steps.length - 1 ? 0 : clamp((t - (i + textSwapAt - textTransition)) / textTransition, 0, 1); const opacity = reduceMotion ? (fadeIn >= 1 && fadeOut < 1 ? 1 : 0) : fadeIn * (1 - fadeOut); const step = steps[i]; step.style.opacity = opacity.toFixed(3); step.style.transform = reduceMotion ? '' : `translate3d(0, ${((1 - fadeIn) * textShift - fadeOut * textShift).toFixed(1)}px, 0)`; step.style.visibility = opacity < 0.02 ? 'hidden' : 'visible'; step.setAttribute('aria-hidden', opacity < 0.5 ? 'true' : 'false'); } // Rounds at the halfway point, so the rail flips with the heading. const active = clamp(Math.round(t), 0, n - 1); ticks.forEach((tick, i) => tick.classList.toggle('is-active', i === active)); }; // One step of the chase: read where the scroll wants the scene to be, ease // towards it, and only paint when that actually moved something. const advance = now => { const dt = clamp(now - lastFrameRef.current, 1, 50); lastFrameRef.current = now; const rect = root.getBoundingClientRect(); if (rect.bottom < -240 || rect.top > window.innerHeight + 240) return; const target = clamp(-rect.top / travel, 0, 1) * n; const diff = target - current; if (Math.abs(diff) < 0.0004) { if (settled) return; current = target; settled = true; } else { // Frame-rate independent lerp, so 120Hz doesn't ease twice as fast. current += diff * (reduceMotion ? 1 : 1 - Math.pow(1 - smoothing, dt / 16.667)); settled = false; } render(current); }; const tick = now => { rafRef.current = requestAnimationFrame(tick); advance(now || performance.now()); }; // The loop only exists while the track is near the viewport. Off-screen it // was still costing a rAF callback and a getBoundingClientRect() every // frame for the whole life of the page — a forced layout on the same main // thread the scroll is being animated on. `will-change` rides the same // switch, so four viewport-sized layers only hold compositor memory while // the scene is the thing on screen. let running = false; const start = () => { if (running || document.hidden) return; running = true; root.classList.add('is-live'); lastFrameRef.current = performance.now(); settled = false; current = targetSteps(); // adopt, don't ease in from wherever we stopped render(current); rafRef.current = requestAnimationFrame(tick); }; const stop = () => { if (!running) return; running = false; cancelAnimationFrame(rafRef.current); rafRef.current = 0; root.classList.remove('is-live'); }; // Both triggers re-derive the answer instead of sharing a cached flag: a // page opened in a background tab gets no IntersectionObserver callbacks // (delivery rides the frame loop, which is frozen), so a flag set only by // the observer is still false when the tab is finally revealed and the // scene would stay frozen. One rect per tab switch or crossing is nothing. // The margin is wide enough that the scene is settled and its layers are // already promoted before any of it is visible, entering from either end. const shouldRun = () => { if (document.hidden) return false; const margin = window.innerHeight * 0.25; const rect = root.getBoundingClientRect(); return rect.bottom > -margin && rect.top < window.innerHeight + margin; }; const sync = () => { if (shouldRun()) start(); // Paint the frame the scroll position calls for on the way out, so the // scene is never left mid-transition once the loop is gone. else { stop(); render(targetSteps()); } }; const io = new IntersectionObserver(sync, { rootMargin: '25% 0px' }); io.observe(root); document.addEventListener('visibilitychange', sync); // Fallback for frames rAF skips (throttled tabs); a no-op while it is alive. const onScroll = () => { if (running) return; const now = performance.now(); if (now - lastFrameRef.current > 40) advance(now); }; const onResize = () => { layout(); render(current); }; lastFrameRef.current = performance.now(); layout(); render(current); sync(); // the observer's first delivery is a frame away, and never arrives // at all if the page loaded in a background tab window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onResize); return () => { io.disconnect(); document.removeEventListener('visibilitychange', sync); cancelAnimationFrame(rafRef.current); root.classList.remove('is-live'); window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onResize); root.style.height = ''; layers.forEach(layer => { layer.style.transform = ''; layer.style.zIndex = ''; }); steps.forEach(step => { step.style.opacity = ''; step.style.transform = ''; step.style.visibility = ''; step.removeAttribute('aria-hidden'); }); }; }, [stepVh, imageTransition, textTransition, textSwapAt, layerScale, textShift, smoothing]); return (