GSAP ScrollTrigger Flying & Spinning Text - Inspired by Saisei's Japanese-Modern Vibe
Learn how to create a stunning text animation with GSAP ScrollTrigger, inspired by Saisei’s Japanese architecture aesthetic. This step-by-step guide covers everything from splitting text into characters to setting up scroll-triggered animations, explaining how ScrollTrigger monitors scrollbar position for smooth, dynamic effects. Perfect for Webflow developers looking to add pro-level animations to their websites!
Alright, here’s the deal: I’ve got a text animation for you that’s going to make people stop scrolling and actually look at your site. This one’s inspired by that gorgeous, clean Japanese architecture site, Saisei. We’re using GSAP ScrollTrigger and SplitType to take basic text and give it the kind of movement that makes each letter feel like a main character. I’ll walk you through it all from start to finish – no fluff, just solid steps.
First Things First – Imports
You’ll need GSAP, ScrollTrigger, and a small library called SplitType
to break up text by characters. Here’s what to grab:
import { gsap } from 'https://cdn.skypack.dev/gsap';
import { ScrollTrigger } from 'https://cdn.skypack.dev/gsap/ScrollTrigger';
import splittype from 'https://cdn.skypack.dev/split-type';
gsap.registerPlugin(ScrollTrigger);
Step 1: Break Up the Text into Characters
We’re animating every single letter, so the first thing I do is split up the text. I’m using SplitType
to handle this – it’ll convert each character into its own little element we can animate individually.
function splitTextIntoChars() {
const textToSplit = document.querySelectorAll('.scroller-text');
new splittype(textToSplit, { types: 'chars' });
}
This lets me target each character on its own, making it super easy to apply different animations to each one.
Step 2: Reveal Elements After Animation Setup
I like starting these characters off hidden so that they reveal themselves only after they’re ready to animate. Adding a .start-hidden
class to your elements in HTML does the trick, and here’s a quick function to unhide them.
function unhideElements() {
const hiddenElements = document.querySelectorAll('.start-hidden');
hiddenElements.forEach((element) => {
element.classList.remove('start-hidden');
});
}
So that I can see the text in Webflow designer view but prevent it from flashing on page load (i.e. the text shows until the GSAP from
tween kicks with our createScrollAnimation
function below), I add this CSS one-liner to the page settings.
<style>
.start-hidden {
opacity: 0;
}
</style>
Now when page loads it goes like this:
- Anything with the class
start-hidden
hasopacity
set to0
so it's invisible. - All of our Javascript runs, which sets up the animation how we want it.
- We remove the
start-hidden
class from all elements that have it, so theopacity
goes back to whatever it was set to in Webflow designer on the base class.
Step 3: Set Up the Scroll Animation
Now we’re at the fun part. I’m using GSAP’s timeline to create a scroll-triggered animation where each character spins, shifts, and scales up into view. Here’s what the setup looks like:
function createScrollAnimation() {
const scrollerSection = document.querySelector('.scroller-section');
const chars = document.querySelectorAll('.char');
const tl = gsap.timeline({
scrollTrigger: {
trigger: scrollerSection,
start: 'top top',
scrub: 1,
pin: true,
end: () => `+=${window.innerHeight * 2}`,
ease: 'none',
// markers: true, // Uncomment if you want to see where ScrollTrigger points are
},
});
tl.fromTo(
chars,
{
rotateY: () => Math.random() * 180 + 180,
yPercent: () => Math.random() * 100 - 50,
xPercent: () => Math.random() * 100 - 50,
scale: 0,
},
{
rotateY: 0,
yPercent: 0,
xPercent: 0,
scale: 1,
stagger: {
amount: 1,
from: 'random',
},
}
);
}
Here’s a breakdown:
- rotateY: Randomizes the spin of each letter so it feels dynamic.
- yPercent & xPercent: Adds randomness to the movement along both axes, creating a slightly chaotic effect.
- scale: Starts each character at 0, so they appear to grow into place.
I’ve pinned the section so the text stays put while the animation plays. And setting scrub: 1
links the animation speed to the scroll speed, giving it feel a smooth feel. The Saisei website is using Lenis for smooth scrolling but I'm not using it here. If you do use Lenis, then set the scrub
property to true
so that you don't have double smoothing.
A Little Digression On How ScrollTrigger Works
I used to think that, since it's a "scroll-triggered" animation, the code would execute every time you scroll, firing continuously as the page moves. But that's not the case.
Here’s what actually happens: the animation is only created once on page load. When we run createScrollAnimation()
, we’re setting up everything needed for the ScrollTrigger to monitor the position of the scrollbar. It’s not repeatedly running the function; instead, ScrollTrigger watches the position of the scrollbar as you scroll the animation progresses based on that.
So, the function createScrollAnimation()
runs once when the page loads. Inside it, we create a GSAP timeline and define exactly how each character should animate. ScrollTrigger then syncs that animation to the scroll position, so when you scroll, it simply plays forward or backward depending on your scroll direction, like a synced video. I named the function createScrollAnimation
instead of something like playScrollAnimation
to try and hammer this concept home. As you debug your scrolling animations, knowing this will change how you approach problems.
Bringing It All Together
Last step – run all this once the DOM is ready, and you’re set.
document.addEventListener('DOMContentLoaded', () => {
splitTextIntoChars();
createScrollAnimation();
unhideElements();
});
And that’s it! You’ve now got a slick, character-level text animation that looks pro and makes every scroll count. Go show it off.
Full Code
import { gsap } from 'https://cdn.skypack.dev/gsap';
import { ScrollTrigger } from 'https://cdn.skypack.dev/gsap/ScrollTrigger';
import splittype from 'https://cdn.skypack.dev/split-type';
gsap.registerPlugin(ScrollTrigger);
function splitTextIntoChars() {
const textToSplit = document.querySelectorAll('.scroller-text');
new splittype(textToSplit, { types: 'chars' });
}
function unhideElements() {
const hiddenElements = document.querySelectorAll('.start-hidden');
hiddenElements.forEach((element) => {
element.classList.remove('start-hidden');
});
}
function createScrollAnimation() {
const scrollerSection = document.querySelector('.scroller-section');
const chars = document.querySelectorAll('.char');
const tl = gsap.timeline({
scrollTrigger: {
trigger: scrollerSection,
start: 'top top',
scrub: 1,
pin: true,
end: () => `+=${window.innerHeight * 2}`,
ease: 'none',
// markers: true,
},
});
tl.fromTo(
chars,
{
rotateY: () => {
return Math.random() * 180 + 180; // Random number between 180 and 360
},
yPercent: () => {
return Math.random() * 100 - 50; // Random number between -50 and 50
},
xPercent: () => {
return Math.random() * 100 - 50; // Random number between -50 and 50
},
scale: 0,
},
{
rotateY: 0,
yPercent: 0,
xPercent: 0,
scale: 1,
stagger: {
amount: 1,
from: 'random',
},
}
);
}
document.addEventListener('DOMContentLoaded', () => {
splitTextIntoChars();
createScrollAnimation();
unhideElements(); // Unhide elements after the animation is created
});