// ScrollReveal — wrap a section to fade + slide it up when it // enters the viewport. One-shot (never animates back out). Uses // IntersectionObserver, no scroll listeners, no main-thread work. // // `prefers-reduced-motion: reduce` short-circuits to instantly visible. import { useEffect, useRef, useState, type ReactNode } from 'react' import { cn } from '../cn' interface ScrollRevealProps { readonly children: ReactNode readonly className?: string /** Pixel distance the element rises from. Default 24. */ readonly distance?: number /** Delay before the animation runs, in ms. Default 0. */ readonly delay?: number /** Intersection threshold 0…1. Default 0.15 — element starts * animating when 15% of it is in view. */ readonly threshold?: number /** Disable the animation entirely (passthrough). Useful when the * parent is already a stagger container. */ readonly disabled?: boolean } export const ScrollReveal = ({ children, className, distance = 24, delay = 0, threshold = 0.15, disabled = false, }: ScrollRevealProps): ReactNode => { const ref = useRef(null) const [shown, setShown] = useState(disabled) useEffect(() => { if (disabled) return if (typeof IntersectionObserver === 'undefined') { // Old browser — just show instantly. setShown(true) return } const node = ref.current if (!node) return const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { setShown(true) observer.disconnect() return } } }, { threshold, rootMargin: '0px 0px -10% 0px' }, ) observer.observe(node) return () => observer.disconnect() }, [threshold, disabled]) return (
{children}
) }