Add stack scrub and reduced-motion hooks

This commit is contained in:
David 2026-11-03 14:09:17 +02:00
parent d9505e783b
commit 2c7a16dccb
2 changed files with 107 additions and 0 deletions

View file

@ -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
}

View file

@ -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<HTMLElement | null>,
cardRefs: RefObject<HTMLElement | null>[],
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])
}