'use client' import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/all" import type { RefObject } from "react" import { GSAP_DEBUG, useGsapContext } from "~/app/_providers/GsapProvicer" export type UseRevealOptions = { position: gsap.Position /** Skip the orchestrated entrance and let ScrollTrigger drive from the start. */ scrollOnly?: boolean /** * Reveal once and keep it: after the element animates in (entrance or first * scroll-in) it never reverses on leave. Default false = animate out at the * top and back in on scroll-up. */ once?: boolean debugId?: string /** * Build the hidden -> shown animation for `el`. It must be a single, * *independent* animation (not added to any timeline): `play()` reveals, * `reverse()` hides. The hook pauses it, schedules its entrance through the * shared timeline, and lets a ScrollTrigger drive the very same animation on * scroll — so the two modes never fight over the element. */ makeReveal: (el: HTMLElement) => gsap.core.Tween | gsap.core.Timeline } /** * Shared reveal behavior for cards, text and pop-ups: an element in view at * load plays an orchestrated timeline entrance, then hands the *same* tween to * a ScrollTrigger that animates it out at the top and back in on scroll-up. An * element off-screen at load is ScrollTrigger-driven from the start. */ export function useReveal( ref: RefObject, { position, scrollOnly = false, once = false, debugId, makeReveal }: UseRevealOptions, ) { const ctx = useGsapContext() useGSAP(() => { const el = ref.current if (!el || !ctx) return const scroller = ctx.getScroller() const scrollerEl = scroller instanceof Element ? scroller : undefined const rect = el.getBoundingClientRect() let top = 0 let bottom = window.innerHeight if (scrollerEl) { const r = scrollerEl.getBoundingClientRect() top = r.top bottom = r.top + r.height } const isInView = rect.bottom > top && rect.top < bottom const reveal = makeReveal(el) // A reveal that animates height (pop-ups) shifts every trigger below it. // Re-measure as it animates so positions track the real layout instead of // only correcting at the very end. requestRefresh is throttled + deferred to // the next frame, so this won't re-enter a ScrollTrigger callback. reveal.eventCallback("onUpdate", () => ctx.requestRefresh()) reveal.pause() const baseTrigger = { trigger: el, start: "top bottom", end: "bottom top", scroller: scrollerEl, markers: GSAP_DEBUG, id: GSAP_DEBUG ? debugId : undefined, } // Full behavior: in at the bottom, out at the top, and back on scroll-up. const addReplayTrigger = () => ScrollTrigger.create({ ...baseTrigger, onEnter: () => reveal.play(), onEnterBack: () => reveal.play(), onLeave: () => reveal.reverse(), onLeaveBack: () => reveal.reverse(), }) if (isInView && !scrollOnly) { // The shared timeline only decides *when* the entrance starts; the reveal // plays independently so the ScrollTrigger can take it over afterwards. ctx.schedule(() => reveal.play(), position) // `once` elements keep their revealed state — no scroll trigger at all. if (!once) ctx.onReady(addReplayTrigger) } else if (isInView) { // scrollOnly + already on screen: no enter crossing will fire, so reveal // now. Keep a trigger for scroll-out unless this is a `once` element. reveal.play() if (!once) addReplayTrigger() } else if (once) { // Off-screen: reveal when first reached, then self-destruct so it never // reverses. ScrollTrigger.create({ ...baseTrigger, once: true, onEnter: () => reveal.play() }) } else { addReplayTrigger() } }, { dependencies: [] }) }