<>Introduction — The Scroll-Driven Canvas Challenge

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 explore if a canvas-based approach would be more performant for shorter, lighter animations. This guide documents that exploration and how I’d have liked to learn it.

Extracting Frames with FFmpeg

I began by converting my short video into a series of individual frames using FFmpeg. I went with 30fps for smoothness, which gave me about 150 frames — probably overkill, but helpful for testing. When I tried 60fps later, performance tanked. So 30fps is a good balance.

ffmpeg -i file-location.mp4 -vf fps=30 file-output/frame_%03d.png
# -vf = video file
# fps=30 = Set the framerate to 30 per second
# file-output/frame_%03d.png = where the file will go and its name will end in 001+

Converting Frames to AVIF Format

PNG was way too heavy to load all 150+ frames. I needed to compress the frames to AVIF. Normally I’d use Webflow’s built-in compression, but this time I turned to avif-cli. It’s fast, flexible, and works well locally.

npx avif --input="file-locations/*.png" --output="output-folder"
# Adjust *.png if your source files are WebP or JPG
# Make sure npx is available — install avif-cli if needed
<>Hosting the Frames (Webflow Option)

My first instinct was to upload all frames into a Webflow asset folder. Unfortunately, Webflow renames each file and generates unique hosted URLs, so I needed a way to fetch those URLs programmatically. I tried the Webflow API using curl, but about 30 assets consistently returned null.

for id in $(curl -s https://api.webflow.com/v2/asset_folders/123 \
  -H "Authorization: Bearer token" | jq -r '.assets[]'); do
  curl -s https://api.webflow.com/v2/assets/$id \
    -H "Authorization: Bearer token" | jq -r '.hostedUrl'
done
# Replaces asset IDs with URLs
# Doesn’t work reliably — some URLs return null

Hosting the Frames (Node.js Alternative)

In hindsight, I should’ve just scripted it in Node.js. Here's a simple example using node-fetch to loop through asset IDs and log hosted URLs. This method was more consistent than using curl.

1const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
2const folderId = 'YOUR_FOLDER_ID';
3const token = 'YOUR_API_TOKEN';
4
5async function run() {
6  const res = await fetch(`https://api.webflow.com/v2/asset_folders/${folderId}`, {
7    headers: { Authorization: `Bearer ${token}` }
8  });
9  const data = await res.json();
10
11  for (const assetId of data.assets) {
12    const res = await fetch(`https://api.webflow.com/v2/assets/${assetId}`, {
13      headers: { Authorization: `Bearer ${token}` }
14    });
15    const data = await res.json();
16    console.log(data.hostedUrl);
17  }
18}
19
20run();
<>Hosting with AWS S3

Eventually I moved to an AWS S3 bucket. It gave me predictable URLs like frame_001.avif, which made the GSAP integration cleaner. For client projects, I'd still use Webflow + script, but for personal projects, AWS gives more control.

https://my-bucket.s3.amazonaws.com/frames/frame_001.avif
https://my-bucket.s3.amazonaws.com/frames/frame_002.avif
<>Webflow Canvas Setup

On your page, add a <canvas> element inside a sticky parent that’s at least 200vh tall. This creates enough scroll distance to drive the animation.

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

<style>
  #sequence {
    width: 100%;
    height: 100%;
  }
</style>

GSAP Image Sequence Setup

GSAP’s ScrollTrigger plugin makes syncing animations with scroll effortless. I built an array of frame URLs, sorted them by frame number, and passed them into a helper function that handles the canvas rendering.

gsap.registerPlugin(ScrollTrigger);

let imgs = new Array(151).fill().map((_, i) =>
  `https://bucket.s3.amazonaws.com/frame_${(i + 1).toString().padStart(3, '0')}.avif`
);

imgs.sort((a, b) => {
  const getFrame = str => parseInt(str.match(/frame_(\d+)/)[1]);
  return getFrame(a) - getFrame(b);
});

heroAnimation();

function heroAnimation() {
  imageSequence({
    urls: imgs,
    canvas: '#sequence',
    scrollTrigger: {
      trigger: '.sticky-parent',
      start: 'top top',
      end: 'bottom bottom',
      scrub: true
    },
    fps: 30
  });
}

The imageSequence Helper

This helper draws each frame to the canvas at the correct time. It calculates scaling to maintain image aspect ratio and prevents unnecessary redraws. Most of it comes from the GSAP docs, but I added some tweaks.

function imageSequence(config) {
  let playhead = { frame: 0 },
      canvas = document.querySelector(config.canvas),
      ctx = canvas.getContext("2d"),
      images = config.urls.map((url, i) => {
        let img = new Image();
        img.src = url;
        if (i === 0) img.onload = updateImage;
        return img;
      }),
      curFrame = -1;

  function updateImage() {
    let frame = Math.round(playhead.frame);
    if (frame === curFrame) return;
    const img = images[frame];
    const scale = Math.max(canvas.width / img.width, canvas.height / img.height);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(img, 
      (canvas.width - img.width * scale) / 2, 
      (canvas.height - img.height * scale) / 2,
      img.width * scale, 
      img.height * scale
    );
    curFrame = frame;
  }

  return gsap.to(playhead, {
    frame: images.length - 1,
    ease: "none",
    onUpdate: updateImage,
    duration: images.length / (config.fps || 30),
    scrollTrigger: config.scrollTrigger
  });
}

Canvas Nuances & Accessibility

A few canvas quirks:

  • You must define width and height inline for crisp rendering
  • Use CSS for responsive styling
  • Canvas isn't accessible — it’s pixels, not semantic content

In my case, the animation was decorative. If your canvas conveys important info, you should provide accessible fallbacks or alt explanations.

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

<style>
  #sequence {
    width: 100%;
    height: 100%;
  }
</style>
Writings

Animated copy to clipboard button

I use this little snippet in almost every project. It's clean, simple and has lots of room for creativity.

How (and why) to add keyboard shortcuts to your Webflow site

A small keyboard shortcut can make a marketing site feel faster, more intentional, and “app-like” with almost no extra design or development

Understanding the FS Attributes API

Since Finsweet released its attributes as open source, I've been dipping in and out to understand how the API works and how it can be used effectively. Here's an explanation of the API methods and properties that I use pretty frequently to open more doors in my projects.

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.

Building a Scroll-Based Image Sequencer with GSAP

An exploration in building a scroll-driven image sequence animation using GSAP and HTML5 canvas. Using Avif file compression with the Avif CLI, hosting strategies (Webflow vs AWS), GSAP and the quirks of working with canvas.