Add stack scrub and reduced-motion hooks
This commit is contained in:
parent
d9505e783b
commit
2c7a16dccb
2 changed files with 107 additions and 0 deletions
20
app/src/hooks/useReducedMotion.ts
Normal file
20
app/src/hooks/useReducedMotion.ts
Normal 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
|
||||
}
|
||||
87
app/src/hooks/useStackScrub.ts
Normal file
87
app/src/hooks/useStackScrub.ts
Normal 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])
|
||||
}
|
||||
Loading…
Reference in a new issue