← Back to list

Interactive Storytelling: Scroll-Linked Video Animations with GSAP

This is what I learned while creating a video scroll effect using GreenSock Animation Platform (GSAP).

Daniel Albinsson - makes an impact · 2025-08-05 12:57 · 0 claps · 4.6 min read
#gsap #video-marketing #react #guides-and-tutorials #gsap-animation
Open on Medium ↗
Wiki topics: ECO · Economy · General LIT · Literature & Writing 🌐 · Web Development 🔒 · Cybersecurity 🎬 · Film & Television

Interactive Storytelling: Scroll-Linked Video Animations with GSAP

Interactive Storytelling: Scroll-Linked Video Animations with GSAP

Interactive Storytelling: Scroll-Linked Video Animations with GSAP

This is what I learned while creating a video scroll effect using GreenSock Animation Platform (GSAP).

This effect links the playback of a video to the user’s scroll position, creating a dynamic and engaging user experience.

Demo: https://fullwidth-video-scroll.vercel.app/

You can find the full code this GitHub repository: https://github.com/danielalbinsson/fullwidth-video-scroll

A key thing for making this work frame by frame with your video is to encode the video so that each frame is an I-frame. This creates a larger than typical file but is needed to this frame scrolling effect.

You can do this using FFMPG with the “-g 1” flag. “-crf” sets the quality, lower is higher quality — and larger file size.

Here is what worked well for me:

ffmpeg -i ~ video/filename.mov \
 -movflags faststart \
 -vcodec libx264 \
 -crf 20 \
 -g 1 \
 -pix_fmt yuv420p \
 output-filename.mp4

Setting up the Scene

First, let’s set up our React component. We’ll be using useRef to get references to our DOM elements and useEffect to initialize our GSAP animations.

Note this example is using next.js that part should not matter.

We’ll need a video element and a few text containers. The video will be pinned to the screen while the text scrolls over it.

Typescript

'use client';

import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

// Register ScrollTrigger plugin
gsap.registerPlugin(ScrollTrigger);

export default function VideoScrollTextPage() {
  const videoRef = useRef<HTMLVideoElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const textContainer1Ref = useRef<HTMLDivElement>(null);
  const textContainer2Ref = useRef<HTMLDivElement>(null);
  const textContainer3Ref = useRef<HTMLDivElement>(null);
  const textContainer4Ref = useRef<HTMLDivElement>(null);
  const mainRef = useRef<HTMLDivElement>(null);

 useEffect(() => {
    // ... animation code will go here
  }, []);

  return (
    <div ref={mainRef} className="bg-black">
      <div
        ref={containerRef}
        className="h-screen bg-black flex items-center justify-center relative overflow-hidden"
      >
        <video
          ref={videoRef}
          className="w-full h-full object-cover"
          muted
          playsInline
          preload="auto"
        >
          <source src="/birds.mp4" type="video/mp4" />
          Your browser does not support the video tag.
        </video>

        <div
          className="absolute w-full"
          style={{ top: '100vh' }} 
        >
            <div ref={textContainer1Ref} className="text-xl flex flex-col py-8 px-12 lg:px-96 text-white">

                <p className="pb-4 max-w-lg">
                    Hope is the thing with feathers —
                </p>
                <p className="pb-4 max-w-lg ">
                    That perches in the soul —
                </p>
                <p className="pb-4 max-w-lg ">
                    And sings the tune without the words —
                </p>
                <p className="max-w-lg pb-4">
                    And never stops — at all —
                </p>

                </div>

                <div ref={textContainer2Ref} className="text-xl flex flex-col py-8 px-12 lg:px-96 text-white">

                <p className="pb-4 max-w-lg ">
                    And sweetest — in the Gale — is heard —
                </p>
                <p className="pb-4 max-w-lg ">
                    And sore must be the storm —
                </p>
                <p className="pb-4 max-w-lg ">
                    That could abash the little Bird
                </p>
                <p className="max-w-lg pb-4">
                    That kept so many warm —
                </p>
                </div>

                <div ref={textContainer3Ref} className="text-xl flex flex-col py-8 px-12 lg:px-96 text-white">
                <p className="pb-4 max-w-lg ">
                    I've heard it in the chillest land —
                </p>
                <p className="pb-4 max-w-lg ">
                    And on the strangest Sea —
                </p>

                <p className="pb-4 max-w-lg ">
                    Yet — never — in Extremity,
                </p>
                <p className="pb-4 max-w-lg">
                    It asked a crumb — of Me.
                </p>
                </div>

                <div ref={textContainer4Ref} className="text-xl flex flex-col py-0 px-12 lg:px-96 text-white">
                <p className="text-md max-w-lg">
                "Hope is the Thing with Feathers"
                by Emily Dickinson
                </p>
            </div>
        </div>

      </div>

      {/* Fin screen */}
      <div className="h-screen bg-white flex items-center justify-center">
        <h1 className="text-6xl font-bold text-black">Fin</h1>
      </div>
    </div>
  );
}

Animating with GSAP and ScrollTrigger

Now for the fun part! Inside our useEffect, we'll set up our GSAP timeline and ScrollTrigger.

const video = videoRef.current;
    const container = containerRef.current;
    const textContainer1 = textContainer1Ref.current;
    const textContainer2 = textContainer2Ref.current;
    const textContainer3 = textContainer3Ref.current;
    const textContainer4 = textContainer4Ref.current;

    if (!video || !container || !textContainer1 || !textContainer2 || !textContainer3 || !textContainer4) return;

    const ctx = gsap.context(() => {
      const handleLoadedMetadata = () => {
        video.currentTime = 0;
        video.pause();

        const tl = gsap.timeline({
          scrollTrigger: {
            trigger: container,
            start: 'top top',
            end: '+=600',
            scrub: true,
            pin: true,
          },
        });

        // Animate video playback
        tl.to(video, {
          currentTime: video.duration,
          ease: 'none',
        }, 0);

        // Animate the text containers with stagger
        tl.to(textContainer1, {
            y: () => -(textContainer1.offsetHeight + window.innerHeight - 280),
            ease: 'expo.out',
        }, 0.04);

        tl.to(textContainer2, {
            y: () => -(textContainer2.offsetHeight + window.innerHeight - 280),
            ease: 'expo.out',
        }, 0.08);

        tl.to(textContainer3, {
            y: () => -(textContainer3.offsetHeight + window.innerHeight - 280),
            ease: 'expo.out',
        }, 0.1);

        tl.to(textContainer4, {
            y: () => -(textContainer4.offsetHeight + window.innerHeight - 100),
            ease: 'expo.out',
        }, 0.12);
      };

      if (video.readyState >= 1) {
        handleLoadedMetadata();
      } else {
        video.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true });
      }
    }, mainRef);

    return () => ctx.revert();    if (!video || !container || !textContainer1 || !textContainer2 || !textContainer3 || !textContainer4) return;

Breaking Down the Code

  • **gsap.registerPlugin(ScrollTrigger):** We start by registering the ScrollTrigger plugin with GSAP.
  • **gsap.context(...):** We use gsap.context() for better cleanup in React. This ensures that our animations are properly reverted when the component unmounts.
  • **handleLoadedMetadata:** We wrap our animation logic in a function that runs after the video's metadata has loaded. This is important because we need the video's duration to animate its currentTime.
  • **gsap.timeline(...):** We create a GSAP timeline and attach a ScrollTrigger to it.
  • trigger: container: The animation starts when the container element enters the viewport.
  • start: 'top top': The animation starts when the top of the container hits the top of the viewport.
  • end: '+=600': The animation lasts for 600 pixels of scroll. This number setts how long the user needs to scroll to get to the end of the video.
  • scrub: true: This is the magic property that links the animation to the scrollbar. Instead of playing the animation once, it scrubs through it as the user scrolls.
  • pin: true: This pins the container element to the screen while the animation is active.
  • Animating the Video:
  • tl.to(video, { currentTime: video.duration, ease: 'none' }, 0);
  • This is the core of the video scroll effect. We’re animating the currentTime of the video from 0 to its full duration. Because we're using scrub, the video will play as the user scrolls down and rewind as they scroll up.

Animating the Text:

  • We use tl.to() to animate the y position of each text container.
  • The y value is calculated dynamically to ensure the text scrolls up and out of the view.
  • We add a slight delay to each text animation (the last parameter in tl.to) to create a staggered effect.

And that’s it!

With just a few lines of code, we’ve created a beautiful and interactive video scroll effect. GSAP’s ScrollTrigger makes it incredibly easy to create complex scroll-based animations.

Experiment with different easing functions, animation properties, and timings to create your own unique effects.

A special thanks to Taryn Elliott on Pexels for the video of the birds.


메타데이터
post_id
87bdf8fb045b
slug
interactive-storytelling-scroll-linked-video-animations-with-gsap-87bdf8fb045b
url
https://medium.com/@i_72880/interactive-storytelling-scroll-linked-video-animations-with-gsap-87bdf8fb045b
canonical_url
https://medium.com/@i_72880/interactive-storytelling-scroll-linked-video-animations-with-gsap-87bdf8fb045b
author_url
https://medium.com/@i_72880
status
ok
fetched_at
2026-08-02 06:19:34