Skip to content

mattseq/how-to-scrollytell

Folders and files

NameName
Last commit message
Last commit date

Latest commit

88e98e1 · · Mar 18, 2026

History

21 Commits
Mar 10, 2026
Jan 10, 2026
Mar 18, 2026
Jan 1, 2026
Jan 1, 2026
Mar 18, 2026
Jan 1, 2026
Jan 1, 2026
Jan 10, 2026
Jan 10, 2026
Mar 10, 2026

Repository files navigation

How To Scrollytell

This is a collection of notes (including a demo) about scrollytelling.

Disclaimer

I am not an expert in scrollytelling - I'm learning alongside you. The purpose of these notes is not to prescribe the “correct” way, but to quickly introduce you to key scrollytelling concepts (like sticky positioning and scroll-based animation) that I’ve discovered through my own research. The techniques shown here reflect my current understanding and are intended as helpful recommendations, not definitive best practices.


What is "Scrollytelling"?

Scrollytelling is a storytelling technique used by websites where content changes and animates in response to the user's scroll actions. As you scroll, a story unfolds, revealing new information, triggering animations, and smoothly transitioning between scenes - creating an interactive, immersive, narrative experience. Some websites that implement this art form extremely well include::


Main Libraries and Frameworks

The necessary libraries and frameworks generally include:

  • An animation framework such as Framer Motion or GSAP
  • A smooth scroll library such as Lenis

In these notes, we're going to use GSAP + Lenis on top of React.


Why Use Lenis?

  • Lenis makes scrolling buttery smooth while allowing for regular CSS transforms, unlike many other smooth scrolling solutions.
  • The most important difference is that it preserves the ability to use position: sticky in CSS, which results in an amazing scrolling experience.
    • Many of the elements you see on Lenis’s own website use position: sticky.
  • A good alternative for Lenis would be using GSAP's ScrollSmoother plugin because it makes things like parallax effects so much easier.
    • "Sticky positioning" will not work if you use ScrollSmoother instead of Lenis and you will have to rely on GSAP's "pin" property.
    • Lenis also makes the scrollbar appear completely smooth as well, which ScrollSmoother fails to do.
    • You can find the docs on ScrollSmoother here.

Why Use GSAP?

  • GSAP is a comprehensive animation framework with lots of support for a variety of different animations, from basic animations to animating text and SVGs.
  • GSAP includes a plugin called ScrollTrigger that provides a way to control animations based on scroll progress.
  • It also includes a property called scrub which allows animations to play forward and backward with scroll, instead of just happening once.
    • Framer Motion does not include scrub, which lets users replay animations like it's a cinematic.
  • I would highly recommend taking a look at GSAP's docs, especially the cheatsheet.

Using GSAP with the ScrollTrigger Plugin

The ScrollTrigger plugin allows you to make amazing scroll-based animations and is obviously a necessity for scrollytelling.

import gsap from "gsap";

import { ReactLenis, useLenis } from "lenis/react";

import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

Sync Lenis's Animation Frame Looping with GSAP's

You should sync Lenis’s animation frame loop with GSAP’s ticker so that animations and scroll smoothing happen on the same timing, on the same loop. This is the recommended way by Lenis.

import gsap from "gsap";

import { ReactLenis } from "lenis/react";

import { useEffect, useRef } from "react";


function App() {
   const lenisRef = useRef();

    useEffect(() =>gt; {
     function update(time) {
       lenisRef.current?.lenis?.raf(performance.now());
     }

      gsap.ticker.add(update);

      return () =>gt; gsap.ticker.remove(update);
   }, []);

    return (
     main>gt;
       ReactLenis root options={{ autoRaf: false }} ref={lenisRef} />gt;
       {/* Rest of Website */}
     main>gt;
   );

}
{ function update(time) { lenisRef.current?.lenis?.raf(performance.now()); } gsap.ticker.add(update); return () => gsap.ticker.remove(update); }, []); return (
{/* Rest of Website */}
); }Website */}
); }} ); }" tabindex="0" role="button">

Example of a GSAP Animation

gsap.fromTo(
   ".box",
   { opacity: 0, x: -500, scale: 0.5 },
   {
     opacity: 1,
     x: 0,
     scale: 1,
     ease: "power3.out",
     scrollTrigger: {
       trigger: ".box",
       start: "top 80%",
       end: "top 30%",
       scrub: true,
     },
   },

);

Key points:


Separating GSAP Animations from Main Code

Because animations rely only on class selectors, they can live in a separate file.

import gsap from "gsap";

import { ScrollTrigger } from "gsap/ScrollTrigger";


gsap.registerPlugin(ScrollTrigger);


export default function GsapAnims() {
   gsap.fromTo(
     ".box",
     { opacity: 0, x: -500, scale: 0.5 },
     {
       opacity: 1,
       x: 0,
       scale: 1,
       ease: "power3.out",
       scrollTrigger: {
         trigger: ".box",
         start: "top 80%",
         end: "top 30%",
         scrub: true,
       },
     },
   );

}

Make sure to clean up ScrollTrigger's listeners with ScrollTrigger.killAll();


Using position: sticky

div
   className="box"
   style={{
     width: 200,
     height: 200,
     background: "hotpink",
     borderRadius: 24,
     margin: "0 auto",
     position: "sticky",
     top: "30%",
   }}

/>gt;
ot;sticky", top: "30%", }} />, }} />" tabindex="0" role="button">

Sticky basics:

Stopping Sticky Positioning

Sticky positioning ends only when the parent element leaves the viewport.

div style={{ height: '100vh', position: 'relative' }}>gt;
   div
     style={{
       position: 'sticky',
       top: 30%, // sticks 30% from the top
       width: 200,
       height: 200,
       background: 'hotpink',
     }}
   />gt;

div>gt;

A GSAP Alternative to position: sticky

There is actually a second way to make an object "sticky", this time using GSAP instead of basic CSS.

gsap.to("#pinned-object", {
   scrollTrigger: {
     trigger: "#pinned-object",
     start: "top 5%",
     end: "top -120%",
     scrub: true,
     pin: true,
   },

});

Obviously, this raises the question: should you use position: sticky with basic CSS or pin: true with GSAP?

Also from now on, I will refer to elements using position: sticky as being "sticky" and elements using GSAP's pin as being "pinned".

--

Creating "Scenes”

"Scenes" are the basic organizational element of scrollytelling.

A basic scene with a sticky object might look like this:

  1. Create a scene container (controls sticky duration)
  2. Add a sticky object
  3. Add non-sticky elements that scroll past them
  4. When the scene ends:
    • Sticky object releases because the scene container is out of the viewport
    • User transitions smoothly to the next scene

Creating a Zoom-In Effect

This effect starts with a single object, which then scales up to fill the viewport, at which point you transition directly into the next scene.

  1. Start with a single object in the scene container (this is object you're going to zoom in on)
  2. Create a scale animation with GSAP and pin the object:
    gsap.to("#pinned-object", {
       scale: 8,
       ease: "power3.out",
       scrollTrigger: {
         trigger: "#pinned-object",
         start: "top 30%",
         end: "top -100%",
         scrub: true,
         pin: true,
       },
    
    });
  3. Setup the next scene container with a background color that matches that of the object that you're scaling
  4. The end property should be set so that the object unpins once the new scene container is in place.
  5. Apply overflow-hidden to the scene container to prevent the scrollbars from appearing because the scaled object overflows the viewport

You can also use GSAP's Flip plugin to do this more cleanly, which is covered below.


Creating a Text Effect

By "text effect" I mean an effect where the characters or words act as individual elements and animate separately. You can do this by using another GSAP plugin called "SplitText".

import gsap from "gsap";

import { SplitText } from "gsap/SplitText";

gsap.registerPlugin(SplitText);

Now you can use SplitText to split a paragraph of text into separate words or characters and animate them separately:

SplitText.create("#title", {
   type: "words, words",
   mask: "lines",
   autoSplit: true,
   onSplit(self) {
     return gsap.from(self.words, {
       scrollTrigger: {
         trigger: "#title",
         start: "top 80%",
         end: "top 40%",
         scrub: true,
       },
       y: 100,
       autoAlpha: 0,
       stagger: 0.25,
     });
   },

});

There are also a few other GSAP plugins that help you manipulate text such as ScrambleText.


Manipulating SVGs

You can use GSAP plugins to manipulate SVGs: DrawSVG for animating drawing and MorphSVG for transitions between SVGs.

DrawSVG

DrawSVG allows you to animate the drawing of an SVG. The main limitation of DrawSVG is that it does not animate the fill of an SVG, it only affects strokes. Remember to keep this in mind when selecting or creating SVGs. Now, Here's a simple example::

gsap.fromTo(
   "#draw-svg path",
   { drawSVG: "0%" },
   {
     scrollTrigger: {
       trigger: "#draw-svg",
       start: "top 100%",
       end: "top -100%",
       scrub: true,
     },
     drawSVG: "100%",
   },

);

The id of draw-svg was applied to the svg tag itself. #draw-svg path selects the path tag inside it. DrawSVG must be applied to a path tag not an svg tag. The rest is pretty self-explanatory.

MorphSVG

MorphSVG allows you to animate the transition between two SVGs. The most common use for this is when clicking a button. Here's an example without using ScrollTrigger::

gsap.to("#initial-morph-svg", {
   ease: "expo.inOut",
   morphSVG: "#final-morph-svg",
   duration: 1,

});

Parallax

Creating a parallax effect is actually surprisingly simple. All you need to do is make each object animate upwards a different amount.

gsap.to(parallaxObject, {
   y: objectSpeed,
   ease: "none",
   scrollTrigger: {
     trigger: "#scene-container-4",
     start: "top bottom",
     end: "bottom top",
     scrub: true,
   },

});
gsap.utils.toArray(".parallax-layer-1").forEach((parallaxObject, i) =>gt; {
   gsap.to(parallaxObject, {
     y: layerSpeed,
     ease: "none",
     scrollTrigger: {
       trigger: "#scene-container-4",
       start: "top bottom",
       end: "bottom top",
       scrub: 1.5,
     },
   });

});
{ gsap.to(parallaxObject, { y: layerSpeed, ease: "none", scrollTrigger: { trigger: "#scene-container-4", start: "top bottom", end: "bottom top", scrub: 1.5, }, }); });ttom top", scrub: 1.5, }, }); }); }); });" tabindex="0" role="button">

GSAP Timeline

GSAP's Timeline allows you to combine multiple animations for a single object. It has also has multiple useful capabilities:

Here's some code from the demo:

const card_tl = gsap.timeline({
   scrollTrigger: {
     trigger: "#card-stack",
     start: "top top",
     end: "top -300%",
     scrub: true,
   },

});

card_tl.fromTo(
   card,
   { x: 1500, scale: 0 },
   {
     x: 0,
     scale: 1,
     ease: "power3.out",
   },

);

card_tl.to(card, {
   x: -200 * i,
   y: 20 * i,
   scale: 0.5,
   ease: "power3.inOut",
   delay: 0.5,

});

Horizontal Scroll

There's one main way to do horizontal scrolling and that's not to do it at all. You can make the illusion of horizontal scrolling by pinning the container so that it stays in the viewport and making animations happen as you scroll. This also gives you a bit more control. The problem with actual horizontal scrolling is that, by default you can't just scroll as usual. You need to press shift then scroll. We obviously don't want that. There are workarounds and maybe even ways to do it cleanly, I just haven't tried to figure it out yet.

Hover Animations

You can use GSAP's Observer plugin to make hover animations easy. Observer has lots of other uses as well too.

import { Observer } from "gsap/Observer";

import gsap from "gsap";


gsap.registerPlugin(Observer);


const element = document.getElementById("my-element");


Observer.create({
   target: element,
   type: "touch, pointer",
   onHover: () =>gt; {
     gsap.to(element, { scale: 1.1, duration: 0.3, ease: "power2.out" });
   },
   onHoverEnd: () =>gt; {
     gsap.to(element, { scale: 1, duration: 0.3, ease: "power2.inOut" });
   },

});
{ gsap.to(element, { scale: 1.1, duration: 0.3, ease: "power2.out" }); }, onHoverEnd: () => { gsap.to(element, { scale: 1, duration: 0.3, ease: "power2.inOut" }); }, });, duration: 0.3, ease: "power2.inOut" }); }, });; }); }, });" tabindex="0" role="button">

Video

For playing videos, we can use the onUpdate() callback in GSAP animation. Just update the video's current time to use the animation's progress.

const video = document.getElementById("video");

if (video) {
   video.addEventListener("loadedmetadata", () =>gt; {
     gsap.to(video, {
       scrollTrigger: {
         trigger: video,
         start: "top 50%",
         end: "bottom -50%",
         scrub: true,
         pin: true,
         onUpdate: (self) =>gt; {
           video.currentTime = self.progress * video.duration;
         },
       },
     });
   });

}
{ gsap.to(video, { scrollTrigger: { trigger: video, start: "top 50%", end: "bottom -50%", scrub: true, pin: true, onUpdate: (self) => { video.currentTime = self.progress * video.duration; }, }, }); }); }ion; }, }, }); }); } }); }); }" tabindex="0" role="button">

You can also use multiple images as frames and switch out images.

const frameCount = 135;


// get image srcs from folder

const images = Array.from(
   { length: frameCount },
   (_, i) =>gt; `/animation/${(i + 1).toString().padStart(4, '0')}.png`,

);


export default function Intro() {
   const canvasRef = useRefHTMLCanvasElement>gt;(null);

    useEffect(() =>gt; {
     let playhead = { frame: 0 };
     const canvas = canvasRef.current;
     const context = canvas?.getContext('2d');
     if (!canvas || !context) return;

      let currentFrame = -1;
     let imgs: HTMLImageElement[] = [];

      // function to render the current frame based on playhead.frame
     function render() {
       const frame = Math.round(playhead.frame);
       if (frame !== currentFrame &∓& imgs[frame]?.complete) {
         assert(canvas &∓& context);
         context.clearRect(0, 0, canvas.width, canvas.height);
         context.drawImage(imgs[frame], 0, 0);
         currentFrame = frame;
       }
     }

      // preload images
     imgs = images.map((src, i) =>gt; {
       const img = new Image();
       img.src = src;
       // render first image when loaded
       i || (img.onload = render);
       return img;
     });

      // animate playhead.frame through frames on scroll
     gsap.to(playhead, {
       frame: frameCount - 1,
       ease: 'none',
       scrollTrigger: {
         trigger: '#intro',
         start: 'top top',
         end: 'top -200%',
         scrub: true,
         pin: true,
         pinnedContainer: '#intro',
         onUpdate: render,
       },
     });
   }, []);

    return (
     div id='intro'>gt;
       canvas id='canvas' ref={canvasRef} width={1920} height={950} />gt;
     div>gt;
   );

}

3D Models

The most popular library using 3D models in websites is Three.js. For React we install both the core engine, the React framework, and another helper library: npm install three @react-three/fiber @react-three/drei

Some common imports include:

import { Canvas } from "@react-three/fiber";

import { useGLTF, useAnimations } from "@react-three/drei";

Here's an example of importing a model and playing an animation on scroll:

export default function Model() {
   const { scene, animations } = useGLTF("/model.glb");
   const { actions } = useAnimations(animations, model);

    useEffect(() =>gt; {
     if (actions["idle"]) {
       actions["idle"].play();
       actions["idle"].paused = true;
     }

      gsap.to(actions["idle"], {
       time: actions["idle"].getClip().duration,
       ease: "none",
       scrollTrigger: {
         trigger: "#canvas",
         start: "top 60%",
         end: "bottom top",
         scrub: true,
       },
     });

      return () =>gt; {
       if (actions["idle"]) actions["idle"].stop();
     };
   }, [actions]);

    return primitive object={scene} scale={0.5} rotation={[0, Math.PI, 0]} />gt;;

}
{ if (actions["idle"]) { actions["idle"].play(); actions["idle"].paused = true; } gsap.to(actions["idle"], { time: actions["idle"].getClip().duration, ease: "none", scrollTrigger: { trigger: "#canvas", start: "top 60%", end: "bottom top", scrub: true, }, }); return () => { if (actions["idle"]) actions["idle"].stop(); }; }, [actions]); return ; }"].stop(); }; }, [actions]); return ; }on={[0, Math.PI, 0]} />; }" tabindex="0" role="button">

Now we use this model component inside our main canvas:

Canvas id="canvas">gt;
   directionalLight intensity={3} position={[-1, 4, 5]} />gt;
   Model />gt;

Canvas>gt;
as>" tabindex="0" role="button">

GSAP Flip

GSAP's Flip plugin is one of the most powerful tools available for scrollytelling although its name is somewhat misleading. FLIP stands for First, Last, Invert, Play. It allows you to animate complex transformations on elements, which includes reparenting them.m.

A simple Flip method to use is Flip.fit() which fits one element to the exact dimensions of the other.

const flipAnim = gsap.timeline();

flipAnim.addLabel("first");


flipAnim.add(
   Flip.fit("#flip-box", "#flip-container-2", {
     duration: 1,
     ease: "power1.inOut",
   }),

);


flipAnim.addLabel("second");


flipAnim.add(
   Flip.fit("#flip-box", "#flip-container-3", {
     duration: 1,
     ease: "power1.inOut",
   }),

);


flipAnim.addLabel("third");


ScrollTrigger.create({
   trigger: "#scene-container-11",
   animation: flipAnim,
   scrub: true,
   start: "top top",
   end: "top -200%",
   markers: true,
   snap: { snapTo: "labels", duration: { min: 0.2, max: 0.3 }, delay: 0.1 },

});

Inspiration

For inspiration for your next - or first - scrollytelling project, take a look at GSAP's showcase.

For cool animations to use, check out GSAP's demos.

About

A collection of notes and demos to explain scrollytelling techniques.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published