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.
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
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
So the next question is. how would you host 150 images so you can add them to the canvas in order.
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.
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
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.
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 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}
Some canvas quirks I ran into:
1<canvas id="sequence" width="1920" height="1080"></canvas>
1#sequence {
2 width: 100%;
3 height: 100%;
4}
That way the canvas content fits the viewport but doesn’t have bad res
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.
Good projects get messy. Great projects get cleaned up. How should we handle technical debt and real-world refactoring?
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.
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.