From 2c7a16dccbeaac2c6ebac1bf65c07b7f205fed4a Mon Sep 17 00:00:00 2001 From: David Date: Tue, 3 Nov 2026 14:09:17 +0200 Subject: [PATCH] Add stack scrub and reduced-motion hooks --- app/src/hooks/useReducedMotion.ts | 20 +++++++ app/src/hooks/useStackScrub.ts | 87 +++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 app/src/hooks/useReducedMotion.ts create mode 100644 app/src/hooks/useStackScrub.ts diff --git a/app/src/hooks/useReducedMotion.ts b/app/src/hooks/useReducedMotion.ts new file mode 100644 index 0000000..0eda412 --- /dev/null +++ b/app/src/hooks/useReducedMotion.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react' + +// True when the visitor asked the OS to reduce motion. +// The initial value is read synchronously so the very first paint already +// shows the static layout — no flash of the animated scene. +export function useReducedMotion() { + const [reduced, setReduced] = useState( + () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches, + ) + + useEffect(() => { + const mq = matchMedia('(prefers-reduced-motion: reduce)') + const update = () => setReduced(mq.matches) + update() + mq.addEventListener('change', update) + return () => mq.removeEventListener('change', update) + }, []) + + return reduced +} diff --git a/app/src/hooks/useStackScrub.ts b/app/src/hooks/useStackScrub.ts new file mode 100644 index 0000000..146704c --- /dev/null +++ b/app/src/hooks/useStackScrub.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, type RefObject } from 'react' +import { cardState, timelineTotal } from '@/lib/stack' + +/** + * Scrubs a stack of cards as the page scrolls. + * + * The track is a tall element and the stage inside it is `position: sticky`, + * so the stage stays put while the track scrolls past. This hook measures how + * far through the track the reader is and writes each card's transform + * straight to the DOM — no React state, so scrolling never re-renders. + * + * `cardRefs` must be a stable array (a `useRef` value, not a fresh literal) + * so the effect does not re-subscribe on every render. + */ +export function useStackScrub( + trackRef: RefObject, + cardRefs: RefObject[], + enabled: boolean, + onActive?: (index: number) => void, +) { + const onActiveRef = useRef(onActive) + const activeRef = useRef(-1) + + useEffect(() => { + onActiveRef.current = onActive + }, [onActive]) + + useEffect(() => { + const track = trackRef.current + if (!track || !enabled) return undefined + + let raf = 0 + let alive = true + let visible = false + + const apply = () => { + const span = track.offsetHeight - window.innerHeight + const p = span <= 0 ? 0 : Math.min(1, Math.max(0, -track.getBoundingClientRect().top / span)) + const cards = cardRefs.map((r) => r.current).filter(Boolean) as HTMLElement[] + if (!cards.length) return + const t = p * timelineTotal(cards.length) + + let active = -1 + cards.forEach((card, i) => { + const s = cardState(i, t) + card.style.transform = `translate3d(0, ${s.yPercent}%, 0) rotate(${s.rotate}deg) scale(${s.scale})` + card.style.opacity = String(s.opacity) + if (s.opacity > 0) active = i + }) + + if (active !== activeRef.current) { + activeRef.current = active + onActiveRef.current?.(active) + } + } + + const tick = () => { + if (!alive) return + if (visible) apply() + raf = requestAnimationFrame(tick) + } + + // Do not measure the page while the deck is off-screen: reading + // getBoundingClientRect forces layout, and the fly-through above already + // runs a canvas loop the whole time. + const io = new IntersectionObserver( + ([e]) => { + visible = e.isIntersecting + if (visible) apply() + }, + { rootMargin: '200px 0px' }, + ) + io.observe(track) + + raf = requestAnimationFrame(tick) + window.addEventListener('resize', apply) + window.addEventListener('orientationchange', apply) + + return () => { + alive = false + cancelAnimationFrame(raf) + io.disconnect() + window.removeEventListener('resize', apply) + window.removeEventListener('orientationchange', apply) + } + }, [trackRef, cardRefs, enabled]) +}