Jump to content
Search Community

zank

Business
  • Posts

    11
  • Joined

  • Last visited

About zank

  • Birthday December 22

Contact Methods

zank's Achievements

  1. 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>
  2. 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
  3. 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
  4. 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
  5. Also, if you think there is a better way, your suggestions are welcome!
  6. 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
  7. zank

    GSAP + Astro

    window.addEventListener("load", ...) should not be needed as by default the script tag is a module and is called once the dom is ready anyway https://docs.astro.build/en/guides/client-side-scripts/#script-processing you may need to use it if you use the script inline (is:inline)
  8. Ended up using pixi js, I leave the code here if someone need, I ll make a codepen if I will have spare time. <div class="background-effect flex items-center justify-center"> <canvas class="orb-canvas"></canvas> </div> <style> .background-effect { position: fixed; width: 100dvw; height: 100dvh; z-index: -1; } .orb-canvas { position: fixed; top: 0; left: 0; width: 100dvw; height: 100dvh; pointer-events: none; } </style> <script> import * as PIXI from "pixi.js"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { createNoise2D } from "simplex-noise"; import { KawaseBlurFilter } from "@pixi/filter-kawase-blur"; import debounce from "debounce"; gsap.registerPlugin(ScrollTrigger); let app: PIXI.Application<PIXI.ICanvas> | null = null; function init() { if (!document.getElementById("hero-section")) { return; } if (app) { app = null; } // return a random number within a range function random(min, max) { return Math.random() * (max - min) + min; } // map a number from 1 range to another function map(n, start1, end1, start2, end2) { return ((n - start1) / (end1 - start1)) * (end2 - start2) + start2; } // Create a new simplex noise instance const noise2D = new createNoise2D(); // Orb class class Orb { // Pixi takes hex colors as hexidecimal literals (0x rather than a string with '#') constructor(fill = 0x000000, startAngle = 0) { // bounds = the area an orb is "allowed" to move within this.bounds = this.setBounds(); // initialise the orb's { x, y } values to a random point within it's bounds this.x = random(this.bounds["x"].min, this.bounds["x"].max); this.y = random(this.bounds["y"].min, this.bounds["y"].max); // how large the orb is vs it's original radius (this will modulate over time) this.scale = 1; // what color is the orb? this.fill = fill; // the original radius of the orb, set relative to window height this.radius = random(window.innerHeight, window.innerHeight * 1.5); // starting points in "time" for the noise/self similar random values this.xOff = random(0, 1000); this.yOff = random(0, 1000); // how quickly the noise/self similar random values step through time this.inc = 0.0001; // PIXI.Graphics is used to draw 2d primitives (in this case a circle) to the canvas this.graphics = new PIXI.Graphics(); this.graphics.alpha = 0.925; this.angle = startAngle ?? random(0, Math.PI * 2); // Random starting angle this.angularVelocity = 0.01; this.selfRotation = 0; // Initialize self-rotation angle this.selfRotationSpeed = 0.0015; // Speed of self-rotation } setBounds() { // how far from the { x, y } origin can each orb move const maxDist = window.innerWidth < 1000 ? window.innerWidth / 3 : window.innerWidth / 5; // the { x, y } origin for each orb (the bottom right of the screen) const originX = window.innerWidth / 1.25; const originY = window.innerWidth < 1000 ? window.innerHeight : window.innerHeight / 1.375; // allow each orb to move x distance away from it's x / y origin return { x: { min: originX - maxDist, max: originX + maxDist, }, y: { min: originY - maxDist, max: originY + maxDist, }, }; } update() { // self similar "psuedo-random" or noise values at a given point in "time" const xNoise = noise2D(this.xOff, this.xOff); const yNoise = noise2D(this.yOff, this.yOff); const scaleNoise = noise2D(this.xOff, this.yOff); // map the xNoise/yNoise values (between -1 and 1) to a point within the orb's bounds this.x = map(xNoise, -1, 1, this.bounds["x"].min, this.bounds["x"].max); this.y = map(yNoise, -1, 1, this.bounds["y"].min, this.bounds["y"].max); // map scaleNoise (between -1 and 1) to a scale value somewhere between half of the orb's original size, and 100% of it's original size this.scale = map(scaleNoise, -1, 1, 0.5, 1.1); // step through "time" this.xOff += this.inc; this.yOff += this.inc; // Circular motion parameters const centerX = window.innerWidth / 2; const centerY = window.innerHeight / 2; const radius = window.innerWidth < 1000 ? window.innerHeight * 1.1 : window.innerHeight; // Adjust the radius as needed // Calculate new x and y based on the angle this.x = centerX + radius * Math.cos(this.angle); this.y = centerY + radius * Math.sin(this.angle); // Update the angle for the next frame this.angle += this.angularVelocity; // Normalize the angle to prevent overflow if (this.angle > Math.PI * 2) { this.angle -= Math.PI * 2; } // Update the self-rotation angle this.selfRotation += this.selfRotationSpeed; // Normalize the self-rotation angle to prevent overflow if (this.selfRotation > Math.PI * 2) { this.selfRotation -= Math.PI * 2; } } render() { // update the PIXI.Graphics position and scale values this.graphics.x = this.x; this.graphics.y = this.y; this.graphics.scale.set(this.scale); // Apply the self-rotation transformation this.graphics.rotation = this.selfRotation; // clear anything currently drawn to graphics this.graphics.clear(); // tell graphics to fill any shapes drawn after this with the orb's fill color this.graphics.beginFill(this.fill); // draw a circle at { 0, 0 } with it's size set by this.radius this.graphics.drawEllipse(0, 0, this.radius, this.radius / 1.2); // let graphics know we won't be filling in any more shapes this.graphics.endFill(); } } // Create PixiJS app app = new PIXI.Application({ // render to <canvas class="orb-canvas"></canvas> view: document.querySelector(".orb-canvas"), // auto adjust size to fit the current window resizeTo: window, autoStart: false, // transparent background transparent: true, antialias: true, }); // Function to render the scene function renderScene() { app.renderer.render(app.stage); } // Create a separate container for the mask const maskContainer = new PIXI.Container(); // Create a container for orbs const orbContainer = new PIXI.Container(); // Create a graphics object to act as the background const background = new PIXI.Graphics(); background.beginFill(0x02fdfe); // Replace with your desired background color background.drawRect(0, 0, app.screen.width, app.screen.height); background.endFill(); // Add the background to the container orbContainer.addChild(background); maskContainer.addChild(orbContainer); app.stage.addChild(maskContainer); orbContainer.filters = [new KawaseBlurFilter(50, 10, true)]; // Set color to #f0a0c5 in exadecimal const color = 0xf0a0c5; // Create a circular mask const mask = new PIXI.Graphics(); // Function to redraw the mask function redrawMask(newRadius) { mask.clear(); mask.beginFill(0x02fdfe); mask.drawCircle(window.innerWidth / 2, window.innerHeight / 2 + window.innerHeight / 4, newRadius); mask.endFill(); } // Initial radius of the mask const initialRadius = 45; // Set your initial radius // Object with a property for the radius const maskRadiusObj = { radius: initialRadius }; maskContainer.addChild(mask); // Add the mask to the mask container const button = new PIXI.Graphics(); // Function to redraw the button function redrawButton(newRadius: number) { button.clear(); button.beginFill(0x000000); button.drawCircle(window.innerWidth / 2, window.innerHeight / 2 + window.innerHeight / 4, newRadius); button.endFill(); } const buttonRadiusObj = { radius: initialRadius - 7 }; maskContainer.addChild(button); function setupScrollTrigger() { gsap.timeline({ defaults: { ease: "power1.inOut" }, scrollTrigger: { scrub: 0.1, start: `500 top`, end: `900 top`, markers: false, pinSpacing: false, }, }) .fromTo( maskRadiusObj, { radius: maskRadiusObj.radius, // Radius to fill the screen }, { radius: Math.max(window.innerWidth, window.innerHeight), // Radius to fill the screen onUpdate: () => { redrawMask(maskRadiusObj.radius); renderScene(); // Call renderScene on radius update }, }, ) .fromTo( buttonRadiusObj, { radius: buttonRadiusObj.radius, }, { radius: 0, onUpdate: () => { redrawButton(buttonRadiusObj.radius); renderScene(); // Call renderScene on radius update }, }, "<", ); } // GSAP animation to expand the radius setTimeout(function () { if (window.scrollY <= 50) { // Initial animation timeline let initialTimeline = gsap .timeline({ defaults: { duration: 0.8, ease: "back.out(1.4)" } }) .fromTo( maskRadiusObj, { radius: 0, }, { radius: initialRadius, // Radius to fill the screen onUpdate: () => { redrawMask(maskRadiusObj.radius); renderScene(); // Call renderScene on mask update }, }, ) .fromTo( buttonRadiusObj, { radius: 0, }, { radius: initialRadius - 7, onUpdate: () => { redrawButton(buttonRadiusObj.radius); renderScene(); // Call renderScene on button update }, }, ">-=0.5", ) .then(() => { setupScrollTrigger(); }); } else { // Directly setup ScrollTrigger if scroll is more than 100 setupScrollTrigger(); } }, 50); // Create orbs const orb1 = new Orb(color, 0); // First orb starts at angle 0 const orb2 = new Orb(color, Math.PI); // Second orb starts 180 degrees (π radians) opposite orbContainer.addChild(orb1.graphics); orbContainer.addChild(orb2.graphics); // Apply the mask to the stage app.stage.mask = mask; // Function containing the ticker logic function renderOrbs() { orb1.update(); orb1.render(); orb2.update(); orb2.render(); renderScene(); } renderOrbs(); const fps = 30; const fpsInterval = 1000 / fps; let then = Date.now(); function startAnimationLoop() { requestAnimationFrame(startAnimationLoop); const now = Date.now(); const elapsed = now - then; if (elapsed > fpsInterval) { // Adjust for specified fps then = now - (elapsed % fpsInterval); renderOrbs(); // Render your scene } } // Flag to indicate if the ticker has started let tickerStarted = false; // Named function for the scroll event function handleScroll() { if (window.scrollY > window.innerHeight / 1.1 && !tickerStarted) { startAnimationLoop(); tickerStarted = true; // Remove the event listener window.removeEventListener("scroll", handleScroll); } } // Scroll event listener // Add the scroll event listener window.addEventListener("scroll", handleScroll); } document.removeEventListener("DOMContentLoaded", init); // astro:page-load document.addEventListener("DOMContentLoaded", init); // astro:page-load </script>
  9. Thanks for all the tips, I've tried removing the filter effect but still couldn't reach good performance, I will try to make the animation with Pixi.js as it seem the most responsive way of archiving the same result with good performance (maybe?)
  10. I've been working on a website for a customer, but I probably made the poor choiche of deciding to animate an svg for the hero section where i animated the position of 2 ellipse with a blur effect. The performance are great on desktop but on mobile it barely even works causing lots of lags. https://dreamteamdesign.majestico.it I'm talking about the animation that start from the circle mask to the end of the animated gradient. What would be the ideal way to animate this? Should I animate using canvas? Do you know if there's an example of something similar? Thanks
×
×
  • Create New...