Jump to content
Search Community

Gsap with astro view transitions integration

zank test
Moderator Tag

Recommended Posts

After having trouble integrating gsap with astro and view transitions (see thread https://discord.com/channels/830184174198718474/1230877829386735616)

I think we found a possible way to do it (thanks @Fryuni for the feedback), just wondering if you can confirm I m not missing some holes.

So essentially I made a proxy function where all the timelines are stored in a variable and before starting any timeline the method clear is called (https://gsap.com/docs/v3/GSAP/Timeline/clear()/).



https://github.com/zanhk/astro-gsap-leak-test/blob/tl-pool/src/scripts/global.ts#L17

import gsap from "gsap";
 
export const gsapTimelines: {
    [key: string]: gsap.core.Timeline;
} = {};
 
export function createTimeline(
    key: string,
    timelineOptions?: gsap.TimelineVars,
) {
    const newTimeline = gsap.timeline(timelineOptions);
    newTimeline.paused(true);
    gsapTimelines[key] = newTimeline;
    return newTimeline;
}
 
export function loadTimeline(
    key: string,
    timelineOptions?: gsap.TimelineVars,
): gsap.core.Timeline {
    // Here take the timeline from the cache if it exists, otherwise create a new one
    const timeline: gsap.core.Timeline =
        gsapTimelines[key] ?? createTimeline(key, timelineOptions);
 
    let clearAdded = false;
 
    const handler: ProxyHandler<gsap.core.Timeline> = {
        get(target: gsap.core.Timeline, prop: PropertyKey, receiver: any): any {
            const origMethod = target[prop as keyof gsap.core.Timeline];
            if (typeof origMethod === "function") {
                return (...args: any[]) => {
                    if (!clearAdded) {
                        target.clear();
                        clearAdded = true;
                    }
                    const result = origMethod.apply(target, args);
 
                    if (prop === "play") {
                        clearAdded = false;
                    }
 
                    // Ensure the proxy maintains the correct this context
                    return result === target ? receiver : result;
                };
            }
            return origMethod;
        },
    };
 
    return new Proxy(timeline, handler);
}



Usage

https://github.com/zanhk/astro-gsap-leak-test/blob/tl-pool/src/pages/to.astro#L53

 

loadTimeline("test")
                .to(".line span", {
                    ease: "power4.out",
                    y: 0,
                    stagger: {
                        amount: 0.3,
                    },
                    duration: 0.5,
                })
                .play();


That way the heap should be cleared before executing the timeline again.

Did I miss something?

Thanks 

Link to comment
Share on other sites

Hi,

 

I don't have any experience with Astro, but maybe you might want to have a look at GSAP Context as it could provide a simpler solution for this:

https://gsap.com/docs/v3/GSAP/gsap.context()

 

Now if this approach is not too convoluted (it doesn't really seems like that), does what you need and works well in production, then just use it: "if it ain't broken, don't fix it" 🤷‍♂️

 

Happy Tweening!

Link to comment
Share on other sites

Thanks,

 

ended up taking a simpler direction

 

import gsap from "gsap";
 
export const pool = new Set<gsap.core.Timeline>();
 
document.addEventListener("astro:after-swap", (ev) => {
    // after swapping with a new page clean all the timelines
    for (const timeline of pool.values()) {
        timeline.kill();
    }
    pool.clear();
});
 
/**
 * Add the timeline to the pool so the pool can be used later on
 * to kill all the timelines so they can be garbage collected
 */
class TimelineRecycler extends gsap.core.Timeline {
    constructor(vars?: gsap.TimelineVars, time?: number) {
        super(vars, time);
        pool.add(this);
    }
}
 
export default {
    ...gsap,
    timeline: (vars?: gsap.TimelineVars) => {
        return new TimelineRecycler(vars);
    },
};

 

Do you know if there is somthing other than timelines that I should "kill" to make sure it get garbage collected before switching to a new page?

 

Thanks

Link to comment
Share on other sites

5 hours ago, zank said:

Do you know if there is somthing other than timelines that I should "kill" to make sure it get garbage collected before switching to a new page?

Ideally every GSAP instance you created Tween/Timeline/ScrollTrigger/Draggable/SpiltText, etc. That's why I suggested the GSAP Context route, you can create/add all those to a GSAP Context instance and when you navigate to another page just use the revert method:

const ctx = gsap.context(() => {});

// Throughout your code add GSAP instances to the GSAP Context one

// Before changing to a new route
ctx.revert(); // BOOM!!!
// All the GSAP instances added to that context are killed and reverted
// Super simple and easy cleanup, you just worry about writing your code
// and create awesome animations, GSAP does the heavy lifting for you.

But once again, if what you're doing right now works and makes the most sense to you, then keep it.

 

Happy Tweening!

Link to comment
Share on other sites

Thanks,

 

can I do something like this, where inside the component I add custom logic which is not related to gsap directives?

 

 
 
<script>
    import gsap from "gsap";
    import { ScrollTrigger } from "gsap/ScrollTrigger";
    import { SplitText } from "gsap/SplitText";

 

    gsap.registerPlugin(ScrollTrigger, SplitText);

 

    let ctx: gsap.Context | null = null;

 

    function componentVisible() {
        return document.getElementById("hero") !== null;
    }

 

    document.addEventListener(
        "astro:page-load",
        () => {
            if (componentVisible()) {
                // Initialize all animations and add all to the current gsap context
                ctx = gsap.context(() => {
                    // Text + buttons reveal
                    const childSplit = new SplitText(
                        "[data-hero-text-reveal]",
                        {
                            type: "lines",
                            linesClass: "split-child",
                        },
                    );
                    const parentSplit = new SplitText(
                        "[data-hero-text-reveal]",
                        {
                            // type: "lines",
                            linesClass: "split-parent",
                        },
                    );

 

                    gsap.timeline()
                        .set("[data-hero-text-reveal]", { opacity: 1 })
                        .from(childSplit.lines, {
                            yPercent: 300,
                            skewY: 7,
                            stagger: 0.2,
                        })
                        .to(
                            "[data-hero-reveal]",
                            {
                                opacity: 1,
                                stagger: 0.1,
                            },
                            "<=",
                        );

 

                    const pathsToAnimate = document.querySelectorAll(
                        '[wb-element="path-to-animate"]',
                    );

 

                    pathsToAnimate.forEach((path) => {
                        const finalPath = path.getAttribute("wb-final-path");

 

                        gsap.timeline({
                            scrollTrigger: {
                                trigger: path,
                                start: "top bottom",
                                end: "bottom top",
                                scrub: 1,
                            },
                        }).to(path, {
                            attr: { d: finalPath || "" },
                            ease: "none",
                        });
                    });
                });
            }
        },
        { once: false },
    );

 

    document.addEventListener("astro:after-swap", () => {
        // Before switching to the new page check and clean the current context
       if (ctx != null) {
            ctx?.revert();
            ctx = null;
          }
    });
</script>
 
 
Also in my case is there any advantage in scoping the context to a specific component?
 
Thanks

 

 

Link to comment
Share on other sites

Sure!

 

Keep in mind that everything in GSAP (with the exception of our useGSAP hook and PIXI Plugin) is framework agnostic, so we thrive in performance, KB size and flexibility. We don't aim to put constraints for our users or make them code through a bunch of hoops in order to make their code work, we want that they can write their code and that it just works.

 

That's why GSAP Context has the add() method. You can create your context and if you want to create a GSAP instance in a method that you need outside the scope of the GSAP Context instance you can add that instance using the add method:

let ctx;

ctx = gsap.context(() => {});

const myMethod = () => {
  // Some logic here
  
  ctx.add(() => {
    // More GSAP instances here
  });
};

// Then later on your code
ctx.revert();// Easy cleanup of EVERYTHING

If you want to have some custom logic outside GSAP Context is fine, no problem there.

 

Happy Tweening!

Link to comment
Share on other sites

Thanks, so I cannot do what I did above but I have to call 

 

ctx.add(() => {
    // More GSAP instances here
 });

 

Only for when I effectively add a gsap animation to the context, correct?

 

So my previous example would result in

 

<script>
    import gsap from "gsap";
    import { ScrollTrigger } from "gsap/ScrollTrigger";
    import { SplitText } from "gsap/SplitText";
 
 
    gsap.registerPlugin(ScrollTrigger, SplitText);
 
 
    let ctx: gsap.Context | null = null;
 
 
    function componentVisible() {
        return document.getElementById("hero") !== null;
    }
 
 
    document.addEventListener(
        "astro:page-load",
        () => {
            if (componentVisible()) {
                // Initialize all animations and add all to the current gsap context
                const childSplit = new SplitText(
                    "[data-hero-text-reveal]",
                    {
                        type: "lines",
                        linesClass: "split-child",
                    },
                );
                const parentSplit = new SplitText(
                    "[data-hero-text-reveal]",
                    {
                        // type: "lines",
                        linesClass: "split-parent",
                    },
                );
 
                const pathsToAnimate = document.querySelectorAll(
                    '[wb-element="path-to-animate"]',
                );
 
 
                ctx = gsap.context(() => {
                    gsap.timeline()
                        .set("[data-hero-text-reveal]", { opacity: 1 })
                        .from(childSplit.lines, {
                            yPercent: 300,
                            skewY: 7,
                            stagger: 0.2,
                        })
                        .to(
                            "[data-hero-reveal]",
                            {
                                opacity: 1,
                                stagger: 0.1,
                            },
                            "<=",
                        );
                });
 
                pathsToAnimate.forEach((path) => {
                    const finalPath = path.getAttribute("wb-final-path");
 
                    ctx.add(() => {
                        gsap.timeline({
                            scrollTrigger: {
                                trigger: path,
                                start: "top bottom",
                                end: "bottom top",
                                scrub: 1,
                            },
                        }).to(path, {
                            attr: { d: finalPath || "" },
                            ease: "none",
                        });
                    });
                   
                });
            }
        },
        { once: false },
    );
 
 
    document.addEventListener("astro:after-swap", () => {
        // Before switching to the new page check and clean the current context
       if (ctx != null) {
          ctx?.revert();
          ctx = null;
       }
    });
</script>

 

---

 

Regarding the scope instead I think that is better to not scope to a specific element as when I switch page the element the context was scoped to would get destroyed and that may cause troble, is my assumption correct?

 

Thanks

 

Link to comment
Share on other sites

Try not to think so much in terms of correct or incorrect, right or wrong. What I suggested was predicated in moving some things outside the GSAP Context scope that maybe don't have to be there, that's all. I was pointing to the fact that you have other options.

 

This boils to personal preference, I like to keep my code as simple as possible while, ideally, avoid methods with large chunks of code that could be difficult to read some time later.

 

You have to find the way that works for you in the best way, nothing more. If your previous code makes more sense to you and works as expected, then use it. If your last code does that, then use that. There is nothing wrong with your first code, with the SplitText instances and the loop inside the GSAP Context scope, that is going to work as you expect, as I mentioned before I was offering some insight that could be useful down the road at some point.

 

Happy Tweening!

Link to comment
Share on other sites

Thanks,

 

If others may need I m currently using

 

import gsap from "gsap";
 
export class LifecycleManager {
    public ctx: gsap.Context | null;
 
    private boundAfterSwapHandler: () => void;
    private boundPageLoadHandlers: Map<string, () => void>;
 
    constructor() {
        this.ctx = null;
        this.boundAfterSwapHandler = this.onChangePage.bind(this);
        this.boundPageLoadHandlers = new Map();
        document.addEventListener("astro:after-swap", this.boundAfterSwapHandler);
    }
 
    /**
     * Initialize the context
     */
    initializeContext(): void {
        if (this.ctx === null) {
            this.ctx = gsap.context(() => {});
        }
    }
 
    /**
     * Check if the component with the given ID exist in the DOM
     * @param id id of the element
     * @returns boolean
     */
    elementExists(id: string): boolean {
        if (!id) throw new Error("ID cannot be null");
        return document.getElementById(id) !== null;
    }
 
    /**
     * Execute the callback when the page is loaded and the component is visible
     * @param id id of the element
     * @param callback callback function
     */
    onElementLoaded(id: string, callback: (ctx: gsap.Context | null) => void): void {
        if (!this.boundPageLoadHandlers.has(id)) {
            const handler = () => this.onPageLoad(id, callback);
            this.boundPageLoadHandlers.set(id, handler);
            document.addEventListener("astro:page-load", handler);
        }
    }
 
    /**
     * Callback for the page load event
     * @param id id of the element
     * @param callback
     */
    onPageLoad(id: string, callback: (ctx: gsap.Context | null) => void): void {
        if (this.elementExists(id)) {
            this.initializeContext();
            callback(this.ctx);
        }
    }
 
    /**
     * Revert the context
     */
    revertContext() {
        if (this.ctx !== null) {
            this.ctx.revert();
            this.ctx = null;
        }
    }
 
    /**
     * When changing page revert the context
     *  and remove the event listeners
     */
    onChangePage(): void {
        this.revertContext();
    }
 
    /**
     * Cleanup all event listeners
     */
    cleanup(): void {
        this.revertContext();
        document.removeEventListener("astro:after-swap", this.boundAfterSwapHandler);
        this.boundPageLoadHandlers.forEach((handler, id) => {
            document.removeEventListener("astro:page-load", handler);
        });
        this.boundPageLoadHandlers.clear();
    }
}
 
export default LifecycleManager;

 

usage
 

 
<script>
    import gsap from "gsap";
    import { ScrollTrigger } from "gsap/ScrollTrigger";
    import { SplitText } from "gsap/SplitText";
    import { LifecycleManager } from "@/services";
 
    gsap.registerPlugin(ScrollTrigger, SplitText);
 
    let manager = new LifecycleManager();
 
    manager.executeWhenVisible("hero", () => {
        manager.ctx?.add(() => {
            const childSplit = new SplitText("[data-hero-text-reveal]", {
                type: "lines",
                linesClass: "split-child",
            });
            const parentSplit = new SplitText("[data-hero-text-reveal]", {
                // type: "lines",
                linesClass: "split-parent",
            });
 
            gsap.timeline()
                .set("[data-hero-text-reveal]", { opacity: 1 })
                .from(childSplit.lines, {
                    yPercent: 300,
                    skewY: 7,
                    stagger: 0.2,
                })
                .to(
                    "[data-hero-reveal]",
                    {
                        opacity: 1,
                        stagger: 0.1,
                    },
                    "<=",
                );
 
            const pathsToAnimate = document.querySelectorAll(
                '[wb-element="path-to-animate"]',
            );
 
            pathsToAnimate.forEach((path) => {
                const finalPath = path.getAttribute("wb-final-path");
 
                gsap.timeline({
                    scrollTrigger: {
                        trigger: path,
                        start: "top bottom",
                        end: "bottom top",
                        scrub: 1,
                    },
                }).to(path, {
                    attr: { d: finalPath || "" },
                    ease: "none",
                });
            });
        });
    });
</script>

 

 

  • Like 1
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...