diff --git a/src/app/_components/Animated/AnimateIn.tsx b/src/app/_components/Animated/AnimateIn.tsx index 054f2ff..4654bf4 100644 --- a/src/app/_components/Animated/AnimateIn.tsx +++ b/src/app/_components/Animated/AnimateIn.tsx @@ -1,62 +1,43 @@ -import { useGSAP } from "@gsap/react"; import { useRef, type HTMLAttributes, type ReactNode } from "react"; -import { useGsapContext } from "~/app/_providers/GsapProvicer"; import { SplitText } from "gsap/SplitText"; import gsap from 'gsap' import { cn } from "~/lib/utils"; +import { useReveal } from "./useReveal"; + const AnimateTextIn = ({ children, animation = "type", position = 0, - tlId = undefined, speed = 1, scrollOnly = false, + once = false, className }: { children: ReactNode, animation?: "type" | "slide", position?: gsap.Position, - tlId?: string, scrollOnly?: boolean, + once?: boolean, speed?: number, className?: HTMLAttributes['className'] }) => { const el = useRef(null) - const gsapContext = useGsapContext(); - useGSAP(() => { - const rect = el.current?.getBoundingClientRect() - const scroller = gsapContext?.getScroller() - console.log(scroller) - let viewportTop = 0 - let viewportBottom = window.innerHeight - if (scroller && scroller instanceof Element) { - const scrollerRect = scroller.getBoundingClientRect() - viewportTop = scrollerRect.top - viewportBottom = scrollerRect.top + scrollerRect.height - } - const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom - console.log(isInView) - const chars = new SplitText(el.current, { type: 'chars' }) - gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0, tlId) - const fromVars = animation === "slide" - ? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() } - : { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() } - if (isInView && !scrollOnly) { - gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position, tlId) - } else { - gsap.from(chars.chars, - { - ...fromVars, - scrollTrigger: { - trigger: el.current, - start: 'top bottom', - end: 'bottom top', - toggleActions: "play reverse play reverse", - scroller - } - }) - } - }, { dependencies: [] }) + useReveal(el, { + position, + scrollOnly, + once, + debugId: `text-${position}`, + makeReveal: (node) => { + // The wrapper starts at opacity 0 (so there's no flash of unsplit text); + // reveal the wrapper and let the per-character tween do the animation. + gsap.set(node, { opacity: 1 }) + const split = new SplitText(node, { type: 'chars' }) + const fromVars = animation === "slide" + ? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut' } + : { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut' } + return gsap.from(split.chars, { ...fromVars, paused: true }) + }, + }) return (
{children} diff --git a/src/app/_components/Animated/AnimatePopUp.tsx b/src/app/_components/Animated/AnimatePopUp.tsx index e7ae443..3b444fd 100644 --- a/src/app/_components/Animated/AnimatePopUp.tsx +++ b/src/app/_components/Animated/AnimatePopUp.tsx @@ -6,16 +6,20 @@ const AnimatePopUp = ({ position, className, duration=1, - ease='elastic' + ease='elastic', + scrollOnly=false, + once=false, }:{ children:ReactNode position:gsap.Position, className?:HTMLAttributes['className'] duration?:number, - ease?:gsap.EaseString|gsap.EaseFunction + ease?:gsap.EaseString|gsap.EaseFunction, + scrollOnly?:boolean, + once?:boolean, }) => { return ( - + ) } diff --git a/src/app/_components/Animated/AnimatedDiv.tsx b/src/app/_components/Animated/AnimatedDiv.tsx index 6a9e2aa..b1c13fc 100644 --- a/src/app/_components/Animated/AnimatedDiv.tsx +++ b/src/app/_components/Animated/AnimatedDiv.tsx @@ -1,36 +1,39 @@ import gsap from "gsap"; -import { type HTMLAttributes, -type ReactNode, useLayoutEffect, useRef } from "react"; -import { useGsapContext } from "~/app/_providers/GsapProvicer"; +import { type HTMLAttributes, type ReactNode, useRef } from "react"; +import { useReveal } from "./useReveal"; + const AnimatedDiv = ( { children, position, className, - animationMode='to', + animationMode = 'to', + scrollOnly = false, + once = false, + debugId, ...tweenVars }: gsap.TweenVars & { - children:ReactNode, - position:gsap.Position, - animationMode?: 'from'|'to', - className?:HTMLAttributes['className'] + children: ReactNode, + position: gsap.Position, + animationMode?: 'from' | 'to', + scrollOnly?: boolean, + once?: boolean, + debugId?: string, + className?: HTMLAttributes['className'] } ) => { - const div = useRef(null); - const gsapContext = useGsapContext() - useLayoutEffect(() => { - let tween:gsap.core.Tween; - switch(animationMode) { - case 'from': - tween = gsap.from(div.current,tweenVars); - break; - case 'to': - tween = gsap.to(div.current,tweenVars); - break; - } - gsapContext?.addAnimation(tween,position) - },[]) + const div = useRef(null) + useReveal(div, { + position, + scrollOnly, + once, + debugId, + makeReveal: (el) => + animationMode === 'from' + ? gsap.from(el, { ...tweenVars, paused: true }) + : gsap.to(el, { ...tweenVars, paused: true }), + }) return (
{children} diff --git a/src/app/_components/Animated/useReveal.ts b/src/app/_components/Animated/useReveal.ts new file mode 100644 index 0000000..3c5dd1a --- /dev/null +++ b/src/app/_components/Animated/useReveal.ts @@ -0,0 +1,102 @@ +'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: [] }) +} diff --git a/src/app/_providers/GsapProvicer.tsx b/src/app/_providers/GsapProvicer.tsx index cc9f12d..90cbe04 100644 --- a/src/app/_providers/GsapProvicer.tsx +++ b/src/app/_providers/GsapProvicer.tsx @@ -9,11 +9,32 @@ gsap.registerPlugin(useGSAP) gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(SplitText) gsap.registerPlugin(GSDevTools) + +// iOS Safari shows/hides its address bar at the scroll extremes, which resizes +// the viewport and makes ScrollTrigger refresh + fire spurious onLeave/onEnter +// toggles (text animating out at the bottom and not coming back). Ignoring those +// mobile-toolbar resizes keeps the real enter/leave reverse behavior intact. +ScrollTrigger.config({ ignoreMobileResize: true }) + +// Flip to true to draw ScrollTrigger start/end markers on every animated +// element and mount the GSDevTools timeline scrubber. Handy for seeing exactly +// where each card's enter/exit lines sit relative to the viewport. +export const GSAP_DEBUG = false const GsapContext = createContext<{ + // Add a real animation (with its own duration) to the entrance timeline. addAnimation: ( animation: gsap.core.TimelineChild, position: gsap.Position ) => void, + // Schedule a zero-duration callback at `position` — used to *start* an + // independent reveal tween so the timeline only orchestrates timing. + schedule: (fn: () => void, position: gsap.Position) => void, + // Run `cb` once the entrance is done (timeline complete or first user scroll). + onReady: (cb: () => void) => void, + // Re-measure all ScrollTriggers (throttled to once per frame). Call it + // whenever an animation changes content height so trigger positions stay + // aligned with the real layout. + requestRefresh: () => void, resetTimeline: () => void, resumeTimeline: () => void, getScroller: () => Element | Window | null @@ -70,29 +91,104 @@ export default function GsapProvider({ children }: { children: ReactNode }) { // } return scrollerRef.current }, []) + const devToolsCreated = useRef(false) useGSAP(() => { if (!tl.current) { tl.current = gsap.timeline({ paused: true }) } + if (GSAP_DEBUG && tl.current && !devToolsCreated.current) { + devToolsCreated.current = true + GSDevTools.create({ animation: tl.current }) + } return () => { console.log("gsap cleanup") } }) const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => { - console.log("add animation to:", position, tl.current !== undefined) tl.current?.add(animation, position); },[]) + const schedule = useCallback((fn: () => void, position: gsap.Position) => { + tl.current?.add(fn, position) + },[]) + + // Handoff: fire registered callbacks once, when the entrance finishes. + const readyFired = useRef(false) + const readyCbs = useRef void>>([]) + const fireReady = useCallback(() => { + if (readyFired.current) return + readyFired.current = true + readyCbs.current.forEach((cb) => cb()) + readyCbs.current = [] + },[]) + const onReady = useCallback((cb: () => void) => { + if (readyFired.current) cb() + else readyCbs.current.push(cb) + },[]) + + // Throttle ScrollTrigger.refresh() to once per frame so the ResizeObserver + // can fire freely while content height animates. + const refreshQueued = useRef(false) + const scheduleRefresh = useCallback(() => { + if (refreshQueued.current) return + refreshQueued.current = true + requestAnimationFrame(() => { + refreshQueued.current = false + ScrollTrigger.refresh() + }) + },[]) + + const scrollCleanup = useRef<(() => void) | null>(null) + const resizeObserver = useRef(null) + const resetTimeline = useCallback(() => { tl.current?.kill() tl.current?.revert() ScrollTrigger.getAll().forEach(st => st.kill()) + resizeObserver.current?.disconnect() + scrollCleanup.current?.() + scrollCleanup.current = null + readyFired.current = false + readyCbs.current = [] tl.current = gsap.timeline({paused:true}) },[]) + const resumeTimeline = useCallback(() => { - console.log("resuming timeline:",tl.current) - tl.current?.resume() - },[]) + const t = tl.current + if (!t) return + // When the orchestrated entrance finishes, hand off to scroll control and + // realign triggers against the now-settled layout. + t.eventCallback("onComplete", () => { fireReady(); ScrollTrigger.refresh() }) + + const scroller = getScroller() + // If the user scrolls before the entrance finishes, snap it to the end and + // switch to scroll control so the timeline and ScrollTriggers never fight + // over the same elements. + scrollCleanup.current?.() + const onFirstScroll = () => { t.progress(1); fireReady() } + scroller?.addEventListener("scroll", onFirstScroll, { once: true, passive: true }) + scrollCleanup.current = () => scroller?.removeEventListener("scroll", onFirstScroll) + + // Continuously realign triggers while content height changes — entrance + // growth, scroll-driven collapses, late-loading media. + if (scroller instanceof Element) { + const target = scroller.firstElementChild ?? scroller + resizeObserver.current?.disconnect() + resizeObserver.current = new ResizeObserver(scheduleRefresh) + resizeObserver.current.observe(target) + } + + t.resume() + },[getScroller, fireReady, scheduleRefresh]) + + // Fonts/markdown/images loading also change content height after the triggers + // were created; refresh so start/end stay aligned with the real card sizes. + useEffect(() => { + const refresh = () => ScrollTrigger.refresh() + window.addEventListener("load", refresh) + document.fonts?.ready.then(refresh).catch(() => {}) + return () => window.removeEventListener("load", refresh) + }, []) return ( - + {children} ) diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 599fa6c..b644c51 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -41,10 +41,10 @@ export default function ProjectsPage() {
- {project.title} + {project.title}
{project.sourceType && ( - + {project.sourceType === "open" ? "Open Source" : "Closed Source"} @@ -62,7 +62,7 @@ export default function ProjectsPage() { {project.description && (
- + {project.description}
@@ -71,7 +71,7 @@ export default function ProjectsPage() { {project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
{project.techStack.stackItems.map((item, k) => ( - + ))}
)} @@ -111,6 +111,11 @@ export default function ProjectsPage() {
))} + {/* Scroll runway: lets the last cards' ScrollTrigger exit points be + reached past the visible area, so on short viewports the second-to-last + card animates out off-screen instead of being frozen mid-exit at the + bottom of the scroll. Tune the height if a card is still caught. */} +
); } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 6585ab9..b59c1c6 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,7 +1,7 @@ -import { useGSAP } from "@gsap/react"; import * as React from "react" +import * as React from "react" import { useRef } from "react"; -import { useGsapContext } from "~/app/_providers/GsapProvicer"; import gsap from 'gsap' +import { useReveal } from "~/app/_components/Animated/useReveal"; import { cn } from "~/lib/utils" function Card({ @@ -27,40 +27,17 @@ function AnimatedCard({ position = 0, size = "default", scrollOnly = false, + once = false, ...props -}: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean }) { - const gsapContext = useGsapContext() +}: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean, once?: boolean }) { const ref = useRef(null) - useGSAP(() => { - const rect = ref.current?.getBoundingClientRect() - const scroller = gsapContext?.getScroller() - console.log(scroller) - let viewportTop = 0 - let viewportBottom = window.innerHeight - if (scroller && scroller instanceof Element) { - const scrollerRect = scroller.getBoundingClientRect() - viewportTop = scrollerRect.top - viewportBottom = scrollerRect.top + scrollerRect.height - } - const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom - console.log(isInView) - const fromVars = { x: -100, opacity: 0, duration: 0.5 } - if (isInView && !scrollOnly) { - gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position) - } else { - gsap.from(ref.current, - { - ...fromVars, - scrollTrigger: { - trigger: ref.current, - start: 'top bottom', - end: 'bottom top', - toggleActions: "play reverse play reverse", - scroller - } - }) - } - }, { dependencies: [] }) + useReveal(ref, { + position, + scrollOnly, + once, + debugId: `card-${position}`, + makeReveal: (el) => gsap.from(el, { x: -100, opacity: 0, duration: 0.5, paused: true }), + }) return (