import { useEffect, useRef, useState, type CSSProperties, type ReactNode, } from 'react'; import { SPRINGS, prefersReducedMotion, type Spring } from './utils.js'; export interface RevealProps { children: ReactNode; /** Entry direction. Default: 'up' (translateY 8px). */ from?: 'up' | 'down' | 'left' | 'right' | 'fade' | 'scale'; /** Distance in pixels for translate variants. Default 8. */ distance?: number; /** Animation duration (ms). Default 320. */ duration?: number; /** Trigger delay (ms). Default 0. */ delay?: number; /** Spring curve preset. Default 'snappy'. */ spring?: Spring; /** IntersectionObserver threshold. Default 0.1. */ threshold?: number; /** Trigger only once (default) vs every time it enters the viewport. */ once?: boolean; /** Bypass animation (e.g. for SSR-stable visual-regression tests). */ disableMotion?: boolean; /** Tailwind escape hatch. */ className?: string; /** Inline style escape hatch — merged AFTER motion vars. */ style?: CSSProperties; } /** * `` — fades + slides a child into view on viewport entry. * * Uses IntersectionObserver + CSS transitions; zero runtime physics. * Automatically disables animation under `prefers-reduced-motion`. * * @example * ```tsx * *

Hero headline

*
* ``` */ export function Reveal({ children, from = 'up', distance = 8, duration = 320, delay = 0, spring = 'snappy', threshold = 0.1, once = true, disableMotion, className, style, }: RevealProps) { const ref = useRef(null); const [visible, setVisible] = useState(false); const reduced = disableMotion ?? prefersReducedMotion(); useEffect(() => { if (reduced) { setVisible(true); return; } const el = ref.current; if (!el || typeof IntersectionObserver === 'undefined') { setVisible(true); return; } const io = new IntersectionObserver( entries => { for (const entry of entries) { if (entry.isIntersecting) { setVisible(true); if (once) io.disconnect(); } else if (!once) { setVisible(false); } } }, { threshold }, ); io.observe(el); return () => io.disconnect(); }, [threshold, once, reduced]); const transform = visible || reduced ? 'none' : initialTransform(from, distance); const opacity = visible || reduced ? 1 : from === 'scale' ? 0.92 : 0; return (
{children}
); } function initialTransform(from: RevealProps['from'], d: number): string { switch (from) { case 'up': return `translate3d(0, ${d}px, 0)`; case 'down': return `translate3d(0, ${-d}px, 0)`; case 'left': return `translate3d(${d}px, 0, 0)`; case 'right': return `translate3d(${-d}px, 0, 0)`; case 'scale': return 'scale(0.96)'; case 'fade': default: return 'none'; } }