Part 15 of 16
Modern animations with GSAP
Integrate GSAP in Astro: hero entrance with SplitText, parallax with ScrollTrigger, coexisting with the reveal and surviving the View Transitions.
For the final touch of modernity, I added GSAP (GreenSock Animation Platform). Since version 3.13 all the plugins, including SplitText and ScrollTrigger, are free. The most interesting part was integrating it with Astro without breaking the View Transitions or prefers-reduced-motion.
Installation
npm install gsap
GSAP is ESM and works great with the Vite/Astro bundler.
The animation script
I created src/scripts/gsap.ts with the plugins registered and all the logic:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
gsap.registerPlugin(ScrollTrigger, SplitText);
Respecting prefers-reduced-motion
Everything goes inside gsap.matchMedia(), so if the user prefers less motion, no animation is created:
const mm = gsap.matchMedia();
mm.add('(prefers-reduced-motion: no-preference)', () => {
// all the animations here
});
Hero entrance with SplitText
The home title is split into words and each one rises with a slight stagger:
const heroTitle = document.querySelector('.hero-title');
if (heroTitle) {
const split = new SplitText(heroTitle, { type: 'words' });
gsap.fromTo(
split.words,
{ yPercent: 110, opacity: 0 },
{ yPercent: 0, opacity: 1, duration: 0.8, ease: 'power3.out', stagger: 0.06 }
);
}
And the rest of the hero (role, bio, objective, buttons) enters in cascade:
gsap.fromTo(
'.hero-role, .hero-bio, .hero-objective, .hero-actions',
{ y: 24, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.7, ease: 'power2.out', stagger: 0.12, delay: 0.35 }
);
To avoid a flash, these elements are hidden with CSS only when there is JavaScript (
html[data-js]) and only if there’s no reduced motion. So with JS, GSAP animates them; without JS or with reduced motion, they show directly.
The gradient text case
The name has a gradient with background-clip: text. When split, the words become their own elements, so I give them the gradient directly:
.hero-title .gradient-text .word {
background-image: linear-gradient(120deg, var(--color-accent), #8b5cf6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
Parallax with ScrollTrigger
For a subtle parallax, I mark the sections in the HTML with data-gsap="parallax" and animate them with scrub, which links the movement to the scroll:
document.querySelectorAll('[data-gsap="parallax"]').forEach((el) => {
gsap.fromTo(
el,
{ yPercent: 0 },
{
yPercent: -5,
ease: 'none',
scrollTrigger: {
trigger: el,
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
}
);
});
<section class="skills" data-gsap="parallax">...</section>
Coexisting with the existing scroll reveal
We already had a scroll reveal with IntersectionObserver ([data-reveal]). The rule so they don’t step on each other:
- The reveal stays for the sections and cards (
[data-reveal]). - GSAP animates the hero (I removed the
data-revealfrom its elements) and adds parallax to marked sections that no longer carrydata-reveal.
That way nobody competes for the same element’s opacity or transform.
The critical part: View Transitions
With Astro’s router, the DOM is replaced when navigating. If we don’t clean up the instances, the ScrollTriggers stay pointing at old elements and the animations duplicate. The fix is the kill/reinit cycle:
function killAll() {
mm?.revert(); // reverts the MatchMedia animations
mm = null;
ScrollTrigger.getAll().forEach((t) => t.kill());
gsap.globalTimeline.clear();
}
function init() {
killAll();
mm = gsap.matchMedia();
// ... animations ...
ScrollTrigger.refresh();
}
document.addEventListener('astro:after-swap', killAll);
document.addEventListener('astro:page-load', init);
init();
astro:after-swap→ whatever pointed at the previous DOM is cleaned up.astro:page-load→ it initializes again (it also fires on initial load).killAll()at the start ofinit()makes the process idempotent: it doesn’t matter if the event fires twice.
Loading only where it’s needed
The script is imported only on the pages with animations (home, about and tutorials), not on all of them:
<script>
import '../scripts/gsap';
</script>
Astro turns it into a shared chunk (~40 KB gzip) that the browser caches and that the pages that don’t use it never download.
With this, the site was complete: animated with GSAP, accessible with prefers-reduced-motion and robust against page transitions. End of the series!