ScrollTrigger Interactions

Lesson 13

ScrollTrigger turns the scroll position into an animation timeline. As the user scrolls, elements fade in, parallax layers shift, and pinned sections unfold stories. With Claude Code, you describe what you see happening as you scroll — and it writes the GSAP for you.

The key prompting pattern

Describe the visual behavior as you experience it scrolling down the page. Don’t say “add a fade”; say “when this section enters the viewport, the cards appear one by one from the bottom.”

Always include this line in your prompt:

gsap.registerPlugin(ScrollTrigger)을 파일 상단에 추가해줘.

Without it, ScrollTrigger won’t activate and nothing will work.

Fade-in on scroll

The simplest use: elements appear as you reach them.

Prompt:
".card 요소들이 스크롤하다가 화면에 들어올 때 아래에서 올라오며 fade in되게 해줘.
start는 'top 80%', stagger 0.1s, ease power2.out.
gsap.registerPlugin(ScrollTrigger)을 상단에 추가해줘."

Claude Code output:

gsap.registerPlugin(ScrollTrigger);

gsap.from('.card', {
  scrollTrigger: {
    trigger: '.card-grid',
    start: 'top 80%',
  },
  y: 32,
  opacity: 0,
  duration: 0.6,
  stagger: 0.1,
  ease: 'power2.out',
});

Parallax with scrub

scrub: true ties the animation directly to scroll position — the animation plays forward as you scroll down and reverses as you scroll back up.

Prompt:
".hero-text가 스크롤할 때 배경보다 느리게 움직이는 parallax 효과를 줘.
y: -80, scrub: 1로 설정해줘."

Claude Code output:

gsap.to('.hero-text', {
  scrollTrigger: {
    trigger: '.hero',
    start: 'top top',
    end: 'bottom top',
    scrub: 1,
  },
  y: -80,
  ease: 'none',
});

Pin + scrub for scroll storytelling

Pinning locks a section in place while you scroll through it, turning scroll distance into animation progress.

Prompt:
".story 섹션을 pin하고, 스크롤하는 동안 3개의 텍스트 패널이 순서대로
나타났다 사라지게 해줘. scrub: 1, pin: true."

Claude Code output:

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: '.story',
    start: 'top top',
    end: '+=200%',
    pin: true,
    scrub: 1,
  },
});

tl.to('.panel-1', { opacity: 1, duration: 1 })
  .to('.panel-1', { opacity: 0, duration: 0.5 })
  .to('.panel-2', { opacity: 1, duration: 1 })
  .to('.panel-2', { opacity: 0, duration: 0.5 })
  .to('.panel-3', { opacity: 1, duration: 1 });

What the demo shows

The demo is a four-section scroll story. Section 1: a grid of cards fades in with stagger as you reach them. Section 2: a heading drifts in parallax as you scroll past. Section 3: a pinned storytelling section where three panels swap in and out as you scrub. Section 4: a number counts from 0% to 100% as you scroll through it. A sticky bar at the top shows overall page scroll progress.

Source Code script.js
gsap.registerPlugin(ScrollTrigger);

// ── Page scroll progress bar ──────────────────────────────────
const progressBar = document.getElementById('progressBar');
const progressLabel = document.getElementById('progressLabel');

ScrollTrigger.create({
  trigger: document.body,
  start: 'top top',
  end: 'bottom bottom',
  onUpdate: self => {
    const pct = Math.round(self.progress * 100);
    progressBar.style.width = pct + '%';
    progressLabel.textContent = pct + '%';
  },
});

// ── Hero entrance ─────────────────────────────────────────────
const heroTl = gsap.timeline({ defaults: { ease: 'power3.out' } });
heroTl
  .from('.site-nav', { y: -16, opacity: 0, duration: 0.4 })
  .from('.eyebrow', { opacity: 0, duration: 0.3 }, '-=0.1')
  .from('.hero h1', { y: 32, opacity: 0, duration: 0.6 }, '-=0.15')
  .from('.sub', { y: 16, opacity: 0, duration: 0.45 }, '-=0.3');

// ── Pattern 01: Stagger fade-in cards ────────────────────────
// When the card grid scrolls into view at 75%, cards appear one by one
gsap.from('.scroll-card', {
  scrollTrigger: {
    trigger: '.card-grid',
    start: 'top 75%',
  },
  y: 32,
  opacity: 0,
  duration: 0.55,
  stagger: 0.1,
  ease: 'power2.out',
});

// Section heading also fades in
gsap.from('.cards-section .section-label, .cards-section h2, .section-sub', {
  scrollTrigger: {
    trigger: '.cards-section',
    start: 'top 80%',
  },
  y: 20,
  opacity: 0,
  duration: 0.5,
  stagger: 0.1,
  ease: 'power2.out',
});

// ── Pattern 02: Parallax with scrub ──────────────────────────
// Heading drifts upward at a slower rate than scroll speed
gsap.to('.parallax-heading', {
  scrollTrigger: {
    trigger: '#parallaxSection',
    start: 'top bottom',
    end: 'bottom top',
    scrub: 1,
  },
  y: -80,
  ease: 'none',
});

// Parallax section label and sub fade in normally
gsap.from('#parallaxSection .section-label, .parallax-sub', {
  scrollTrigger: {
    trigger: '#parallaxSection',
    start: 'top 75%',
  },
  opacity: 0,
  y: 16,
  duration: 0.6,
  stagger: 0.15,
  ease: 'power2.out',
});

// ── Pattern 03: Pinned storytelling ──────────────────────────
// Section pins in place; 3 panels swap as you scroll through
const storyTl = gsap.timeline({
  scrollTrigger: {
    trigger: '#storySection',
    start: 'top top',
    end: '+=300%',
    pin: true,
    scrub: 1,
    anticipatePin: 1,
  },
});

// Panel 1 is already visible (opacity: 1 in CSS)
// Fade out panel 1, fade in panel 2
storyTl
  .to('#panel1', { opacity: 0, y: -20, duration: 0.4 }, 0.4)
  .fromTo('#panel2', { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.4 }, 0.6)
  .to('#panel2', { opacity: 0, y: -20, duration: 0.4 }, 1.4)
  .fromTo('#panel3', { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.4 }, 1.6);

// ── Pattern 04: onUpdate progress counter ────────────────────
// A number counts from 0 to 100 driven by scroll progress
const counterNumber = document.getElementById('counterNumber');
const counterFill = document.getElementById('counterFill');
const counterNote = document.querySelector('.counter-note');

ScrollTrigger.create({
  trigger: '#counterSection',
  start: 'top 80%',
  end: 'bottom 20%',
  onUpdate: self => {
    const pct = Math.round(self.progress * 100);
    counterNumber.textContent = pct + '%';
    counterFill.style.width = pct + '%';
  },
  onEnter: () => {
    counterNote.textContent = 'Counting...';
  },
  onLeave: () => {
    counterNote.textContent = 'Done ✓';
  },
  onLeaveBack: () => {
    counterNote.textContent = 'Keep scrolling ↓';
  },
});

// Counter section label + heading entrance
gsap.from('.counter-section .section-label, .counter-section h2, .counter-section .section-sub', {
  scrollTrigger: {
    trigger: '#counterSection',
    start: 'top 80%',
  },
  y: 20,
  opacity: 0,
  duration: 0.5,
  stagger: 0.1,
  ease: 'power2.out',
});

// Footer fade in
gsap.from('.footer-pad', {
  scrollTrigger: {
    trigger: '.footer-pad',
    start: 'top 90%',
  },
  opacity: 0,
  y: 16,
  duration: 0.5,
  ease: 'power2.out',
});