Back home

Building a Scroll-Based Image Sequencer with GSAP

May 25, 2025

I’ve always loved those silky-smooth, scroll-driven animations you see on high-end sites — like a video tied to scroll that plays out like a mini movie.

Normally I’d use the Vimeo API + GSAP scrolltrigger to handle it. But I wanted to know if a canvas based approach would be more performant for smaller animations.

In this article my goal is to explain the concept and exploration as I would have liked to learn it.

Breaking Down the Video

I started with a short clip I exported from premiere pro and wanted to turn into individual frames.

I’ve used ffmpeg before to compress videos and found that it had a command to split videos into it’s frames.

1ffmpeg -i file-location.mp4 -vf fps=30 file-output/frame_%03d.png

I went with 30fps, which gave me about 150 frames. Probably overkill, but I was curious to see how far I could push the smoothness.

Note: Later on I tried 60fps and the performance was horrendous

Converting to AVIF

PNG is WAAAY to heavy. So I needed to compress all the files to AVIF.

9/10 times I use the Webflow compression as it’s faster and very reliable but I didn’t have that luxury here. So I used avif-cli via npm:

1npx avif --input="file-locations/*.webp" --output="output-folder"

The command above assumes you already have WebPs, but you can also point it at PNGs by swapping the *.webp to *.png or *.jpg

Hosting the Frames

So the next question is. how would you host 150 images so you can add them to the canvas in order.

Option 1: Webflow

This was my first attempt. I uploaded all the frames into a Webflow folder and thought I’d be clever by scripting the asset retrieval.

The issue: Webflow renames everything and gives each file a unique hosted URL.

For example:

1https://cdn.prod.website-files.com/67f8ebcae6e93dd5ea335c00/681cabb31fb837fcfb503530_your-file-name.webp

So I tried grabbing the URLs via the Webflow API:

1for id in $(
2  curl -s https://api.webflow.com/v2/asset_folders/123123123123123 \
3    -H "Authorization: Bearer token" |
4  jq -r '.assets[]'
5); do
6  curl -s https://api.webflow.com/v2/assets/$id \
7    -H "Authorization: Bearer token" |
8  jq -r '.hostedUrl'
9done

This mostly worked… except it randomly returned null for about 30 of the images. I don’t know why. After a few retries I dropped it and went with option B

I did all this in the terminal because I was lazy. Looking back it would have been a lot easier to just create a JS file and run it with node.

Node JS

1const fetch = (...args) =>
2  import('node-fetch').then(({ default: fetch }) => fetch(...args));
3
4const folderId = '6806535e20562ef894dcdbd1';
5const token = 'YOUR_WEBFLOW_API_TOKEN';
6
7const headers = {
8  'Authorization': `Bearer ${token}`,
9  'Content-Type': 'application/json'
10};
11
12async function run() {
13  const res = await fetch(`https://api.webflow.com/v2/asset_folders/${folderId}`, { headers });
14  const data = await res.json();
15  const assetIds = data.assets;
16
17  for (const assetId of assetIds) {
18    const res = await fetch(`https://api.webflow.com/v2/assets/${assetId}`, { headers });
19    const data = await res.json();
20    console.log(data.hostedUrl);
21  }
22}
23
24run();

Then run the file with node

1node your-file-name.js

If you don’t have node-fetch installed, you’ll need that

1npm install node-fetch

If you don’t have NPM installed. I’d recommend googling that, there’s a sea of guides better than I can write

Option 2: AWS S3

Eventually, I switched to using an AWS S3 bucket. Mostly because I wanted predictable URLs like:

1https://my-bucket.s3.amazonaws.com/frames/frame_001.avif

That made the GSAP integration way easier.

If I was doing this for a personal project I’d use AWS but for a client it wouldn’t be worth the setup and hassle. I’d use Webflow and the node script to get the urls and sort them.

Webflow Setup

This part was nice and simple. On the page you’ll need a HTML Embed that holds your canvas that’s position sticky.

1<canvas id="sequence" width="1920" height="1080"></canvas>

Make sure the sticky parent is 200vh+ so there’s some scroll distance for your animation.

GSAP

GSAP has a helper function for the image sequencer that I took from the docs. You can read more about it here.

I’ll explain the code in the comments

1// Registering the ScrollTrigger plugin
2gsap.registerPlugin(ScrollTrigger);
3
4// Creating an array of our images
5let imgs = new Array(151)
6  .fill()
7  .map((o, i) =>
8    `https://bucket.s3.eu-west-3.amazonaws.com/your-files/frame_${(i + 1)
9      .toString()
10      .padStart(3, '0')}.avif`
11  );
12
13// Sort images by frame number
14imgs.sort((a, b) => {
15  const frameA = parseInt(a.match(/frame_(\d+)/)[1]);
16  const frameB = parseInt(b.match(/frame_(\d+)/)[1]);
17  return frameA - frameB;
18});
19
20// The main animation function
21function heroAnimation() {
22  // Calling the helper function we have below
23  // (functions can be called before they're written because of JS hoisting).
24  imageSequence({
25    // Attaching our URLS
26    urls: imgs,
27
28    // Connecting the canvas
29    canvas: '#sequence',
30
31    // Creating the scroll trigger object
32    scrollTrigger: {
33      // Our trigger is the parent element
34      trigger: '.sticky-parent',
35      // Start when the top of the element hits the top of the viewport
36      start: 'top top',
37      // End when the bottom of the element hits the bottom of the viewport
38      end: 'bottom bottom',
39      // Animate while scrolling
40      scrub: true,
41    },
42
43    // This is quite self explanatory
44    fps: 30,
45  });
46}
47
48// Run our animation
49heroAnimation();
50
51// This was from GSAP. I'm not going to explain everything as it's very well documented on the docs 
52// but essentially it's redrawing the image on the canvas each time the GSAP engine updates
53function imageSequence(config) {
54  let playhead = { frame: 0 },
55    canvas = gsap.utils.toArray(config.canvas)[0] || console.warn("canvas not defined"),
56    ctx = canvas.getContext("2d"),
57    curFrame = -1,
58    onUpdate = config.onUpdate,
59    images,
60    updateImage = function () {
61      let frame = Math.round(playhead.frame);
62      if (frame !== curFrame) { // only draw if necessary
63        config.clear && ctx.clearRect(0, 0, canvas.width, canvas.height);
64        // Calculate scaling to cover the canvas
65        const img = images[frame];
66        const scale = Math.max(canvas.width / img.width, canvas.height / img.height);
67        const x = (canvas.width - img.width * scale) / 2;
68        const y = (canvas.height - img.height * scale) / 2;
69        ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
70        curFrame = frame;
71        onUpdate && onUpdate.call(this, frame, images[frame]);
72      }
73    };
74
75  images = config.urls.map((url, i) => {
76    let img = new Image();
77    img.src = url;
78    i || (img.onload = updateImage);
79    return img;
80  });
81
82  return gsap.to(playhead, {
83    frame: images.length - 1,
84    ease: "none",
85    onUpdate: updateImage,
86    duration: images.length / (config.fps || 30),
87    paused: !!config.paused,
88    scrollTrigger: config.scrollTrigger
89  });
90}


Canvas nuances.

Some canvas quirks I ran into:

  • You need to set width and height inline in the HTML so the canvas resolution matches your image. Otherwise things look blurry
1<canvas id="sequence" width="1920" height="1080"></canvas>
  • Then you can style the canvas with CSS for responsiveness:
1#sequence {
2	width: 100%;
3	height: 100%;
4}


That way the canvas content fits the viewport but doesn’t have bad res

Accessibility considerations

Canvas doesn’t expose content to screen readers — it’s just pixels. But in this case, the animation was decorative, so I was comfortable leaving it as-is. If your animation conveys important info, you’ll need to provide an accessible fallback.

Writings

Launch day isn't the finish line

Good projects get messy. Great projects get cleaned up. How should we handle technical debt and real-world refactoring?

Useful GSAP utilities

A practical, code-heavy dive into GSAP’s utility functions—keyframes, pipe, clamp, normalize, and interpolate—and why they’re so much more than just shortcuts for animation math.

Using Functions as Property Values in GSAP (And Why You Probably Should)

GSAP lets you pass _functions_ as property values. I've known this for a while but never really explored it particularly deeply. Over the last couple of weeks I've been testing, experimenting and getting creative with it to deepen my understanding.

Organising JavaScript in Webflow: Exploring Scalable Patterns

Exploring ways to keep JavaScript modular and maintainable in Webflow — from Slater to GitHub to a custom window.functions pattern. A look at what’s worked (and what hasn’t) while building more scalable websites.