Blog

Integrating Svelte 5 and GSAP 3

Published on Mar 9, 2025

Inspired by this post

GSAP is, in my opinion, the go-to library for creating page animations.

If however you have ever tried to use GSAP with Svelte you will know that it can be a cluttered mess of code and a bit fidgety to use. I mean just take a look at the code I was using for my website.

<script>
	let heroSection;
	let philosophySection;
	let projectsSection;
	let experienceSection;
	let contactSection;

	onMount(() => {
		gsap.registerPlugin(ScrollTrigger);
		// Hero section animations
		gsap.from(heroSection.querySelector('h1'), {
			y: 30,
			opacity: 0,
			duration: 1,
			delay: 0.5
		});

		gsap.from(heroSection.querySelector('.hero-text'), {
			y: 20,
			opacity: 0,
			duration: 1,
			delay: 0.8
		});

    // and so on...
  });
</script>

Obviously there is a better way to do this, after finding the post I linked above it already made things a lot easier!

// animate.js
import { gsap } from 'gsap';

export function animate(node, { type, ...args }) {
  let method = gsap[type];
  return method(node, args);
}
<!-- +page.svelte -->
<a
  use:animate={{
    type: 'from',
    duration: 1,
    scale: 0.9,
    opacity: 0.5,
    ease: 'expo.inOut',
  }}
>Link</a>

There is still a problem with this though. Since it’s in JavaScript, there is no type checking and you can’t use the autocomplete in your IDE.

After making a few changes it now fully supports TypeScript and meets all the requirements of a Svelte action so the LSP doesn’t complain.

// animate.ts
import { gsap } from 'gsap';

type AnimationType = keyof typeof gsap;

interface AnimationOptions extends GSAPTweenVars {
    type: AnimationType;
}

export function animate(
    node: HTMLElement,
    { type, ...args }: AnimationOptions
): { destroy?: () => void } {
    const method = gsap[type] as
        | ((target: gsap.TweenTarget, vars: GSAPTweenVars) => GSAPTween)
        | undefined;

    if (!method) {
        console.warn(`GSAP method "${type}" does not exist.`);
        return {};
    }

    // Create the animation
    const tween = method(node, args);

    return {
        destroy() {
            // Kill the animation when the element is removed
            tween.kill();
        }
    };
}

But there is still one more issue, the scrollTrigger option doesn’t work! And adding gsap.registerPlugin(ScrollTrigger); to your onMount function doesn’t work either. A little extra work is needed to get this working.

// animate.ts
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

// Register the ScrollTrigger plugin with GSAP
gsap.registerPlugin(ScrollTrigger);

type AnimationType = keyof typeof gsap;

interface AnimationOptions extends GSAPTweenVars {
	type: AnimationType;
	scrollTrigger?: ScrollTrigger.Vars;
}

export function animate(
	node: HTMLElement,
	{ type, scrollTrigger, ...args }: AnimationOptions
): { destroy?: () => void } {
	const method = gsap[type] as
		| ((target: gsap.TweenTarget, vars: GSAPTweenVars) => GSAPTween)
		| undefined;

	if (!method) {
		console.warn(`GSAP method "${type}" does not exist.`);
		return {};
	}

	// Create the animation with ScrollTrigger if provided
	const tween = method(node, {
		...args,
		scrollTrigger: scrollTrigger
			? {
					...scrollTrigger,
					trigger: scrollTrigger.trigger || node
				}
			: undefined
	});

	return {
		destroy() {
			// Kill the animation when the element is removed
			tween.kill();

			// If using ScrollTrigger, make sure to kill that instance too
			if (scrollTrigger && tween.scrollTrigger) {
				tween.scrollTrigger.kill();
			}
		}
	};
}

This updated code registers the ScrollTrigger plugin with GSAP and allows you to pass in a scrollTrigger option to the animate function. In doing this it will automatically set the element you have used this action on as the scrollTrigger.trigger element!

Now you can use it like this and have full type checking in your IDE:

<div class="overflow-hidden">
  <h1
    class="font-serif text-7xl font-medium"
    use:animate={{
      type: 'from',
      duration: 1,
      yPercent: 100,
      ease: 'power4.out',
      scrollTrigger: {
        start: 'top 70%',
        end: 'top 20%',
        toggleActions: 'play none none reverse'
      }
    }}
  >
    Other content
  </h1>
</div>

I hope this helps and massive shoutout to Khutso Siema for the original post.