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.
ffmpeg -i file-location.mp4 -vf fps=30 file-output/frame_%03d.pngI 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:
npx 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:
https://cdn.prod.website-files.com/67f8ebcae6e93dd5ea335c00/681cabb31fb837fcfb503530_your-file-name.webpSo I tried grabbing the URLs via the Webflow API:
for id in $(
curl -s https://api.webflow.com/v2/asset_folders/123123123123123 \
-H "Authorization: Bearer token" |
jq -r '.assets[]'
); do
curl -s https://api.webflow.com/v2/assets/$id \
-H "Authorization: Bearer token" |
jq -r '.hostedUrl'
doneThis 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
const fetch = (...args) =>
import('node-fetch').then(({ default: fetch }) => fetch(...args));
const folderId = '6806535e20562ef894dcdbd1';
const token = 'YOUR_WEBFLOW_API_TOKEN';
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
async function run() {
const res = await fetch(`https://api.webflow.com/v2/asset_folders/${folderId}`, { headers });
const data = await res.json();
const assetIds = data.assets;
for (const assetId of assetIds) {
const res = await fetch(`https://api.webflow.com/v2/assets/${assetId}`, { headers });
const data = await res.json();
console.log(data.hostedUrl);
}
}
run();Then run the file with node
node your-file-name.jsIf you don't have node-fetch installed, you'll need that
npm install node-fetchIf 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:
https://my-bucket.s3.amazonaws.com/frames/frame_001.avifThat 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.
<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
// Registering the ScrollTrigger plugin
gsap.registerPlugin(ScrollTrigger);
// Creating an array of our images
let imgs = new Array(151)
.fill()
.map((o, i) =>
`https://bucket.s3.eu-west-3.amazonaws.com/your-files/frame_${(i + 1)
.toString()
.padStart(3, '0')}.avif`
);
// Sort images by frame number
imgs.sort((a, b) => {
const frameA = parseInt(a.match(/frame_(\d+)/)[1]);
const frameB = parseInt(b.match(/frame_(\d+)/)[1]);
return frameA - frameB;
});
// The main animation function
function heroAnimation() {
// Calling the helper function we have below
// (functions can be called before they're written because of JS hoisting).
imageSequence({
// Attaching our URLS
urls: imgs,
// Connecting the canvas
canvas: '#sequence',
// Creating the scroll trigger object
scrollTrigger: {
// Our trigger is the parent element
trigger: '.sticky-parent',
// Start when the top of the element hits the top of the viewport
start: 'top top',
// End when the bottom of the element hits the bottom of the viewport
end: 'bottom bottom',
// Animate while scrolling
scrub: true,
},
// This is quite self explanatory
fps: 30,
});
}
// Run our animation
heroAnimation();
// This was from GSAP. I'm not going to explain everything as it's very well documented on the docs
// but essentially it's redrawing the image on the canvas each time the GSAP engine updates
function imageSequence(config) {
let playhead = { frame: 0 },
canvas = gsap.utils.toArray(config.canvas)[0] || console.warn("canvas not defined"),
ctx = canvas.getContext("2d"),
curFrame = -1,
onUpdate = config.onUpdate,
images,
updateImage = function () {
let frame = Math.round(playhead.frame);
if (frame !== curFrame) { // only draw if necessary
config.clear && ctx.clearRect(0, 0, canvas.width, canvas.height);
// Calculate scaling to cover the canvas
const img = images[frame];
const scale = Math.max(canvas.width / img.width, canvas.height / img.height);
const x = (canvas.width - img.width * scale) / 2;
const y = (canvas.height - img.height * scale) / 2;
ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
curFrame = frame;
onUpdate && onUpdate.call(this, frame, images[frame]);
}
};
images = config.urls.map((url, i) => {
let img = new Image();
img.src = url;
i || (img.onload = updateImage);
return img;
});
return gsap.to(playhead, {
frame: images.length - 1,
ease: "none",
onUpdate: updateImage,
duration: images.length / (config.fps || 30),
paused: !!config.paused,
scrollTrigger: config.scrollTrigger
});
}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
<canvas id="sequence" width="1920" height="1080"></canvas>Then you can style the canvas with CSS for responsiveness:
#sequence {
width: 100%;
height: 100%;
}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.