"use client"; import { Children, useEffect, useRef, type ReactNode, type RefObject } from "react"; import { useReducedMotion } from "motion/react"; type ScrollRevealProps = { /** Each direct child reveals on its own. */ children: ReactNode; /** Side each child slides in from. */ from?: "bottom" | "top" | "left" | "right"; /** Pixels each child travels. */ distance?: number; /** Seconds one child takes. */ duration?: number; /** Seconds between children that come into view together. */ stagger?: number; /** Seconds before the first child starts. */ delay?: number; /** How much of a child must show before it starts, 0 to 1. */ amount?: number; /** False hides children again when they leave, so they replay. */ once?: boolean; /** Scrolling box that holds this. Leave out to use the page. */ root?: RefObject; /** On the wrapper, so a grid or flex layout goes here. */ className?: string; }; const SHIFT = { bottom: [0, 1], top: [0, -1], left: [-1, 0], right: [1, 0] }; /* ScrollReveal: children slide and fade in as they scroll into view. Those arriving together go one after another. */ export function ScrollReveal({ children, from = "bottom", distance = 28, duration = 0.6, stagger = 0.08, delay = 0, amount = 0.25, once = true, root, className = "", }: ScrollRevealProps) { const reduce = useReducedMotion(); const box = useRef(null); const count = Children.count(children); const [dx, dy] = SHIFT[from]; const hidden = `translate3d(${dx * distance}px, ${dy * distance}px, 0)`; // The outer slot is watched and the inner layer moves, so the shift cannot change what is seen. useEffect(() => { if (!box.current) return; const inner = (slot: Element) => slot.firstElementChild as HTMLElement; const slots = [...box.current.children]; if (reduce) slots.forEach((slot) => (inner(slot).style.transform = "none")); const observer = new IntersectionObserver( (entries) => { let order = 0; for (const { target, isIntersecting } of entries) { const el = inner(target); if (isIntersecting) { el.style.transitionDelay = `${delay + order++ * stagger}s`; el.style.opacity = "1"; el.style.transform = "none"; if (once) observer.unobserve(target); } else if (!once) { el.style.transitionDelay = "0s"; el.style.opacity = "0"; if (!reduce) el.style.transform = hidden; } } }, { root: root?.current, threshold: amount }, ); slots.forEach((slot) => observer.observe(slot)); return () => observer.disconnect(); }, [reduce, hidden, stagger, delay, amount, once, root, count]); return (
{Children.map(children, (child) => (
{child}
))}
); }