Slicing up a video

Nov 29th, 2025

Some notes I took while implementing a new effect for my website (see this post for the effect). We can "warp" a real DOM node by slicing it up, rendering the same node 10 times but using a clip path to show segments, and applying a different transform to each slice.

I'm using this to have a "paper" effect where content falls down like a piece of paper. What's cool is this is a real DOM node, so we can even do this with animated gifs/videos.

However... we want the papers to fall immediately when the user hits the page. We don't want to wait for the gif to download; on slow connections that can be quite slow (and gifs are large).

How can we optimize this?

Videos might help. There are two big advantages to videos:

  • They are WAY smaller than gifs
  • The browser sends us an even when there is enough data download to play the video (so we can start the animation even though the whole thing isn't downloaded yet)

Experiment of using a video

I tried using a video instead of a gif. I used ffmpeg to convert the gif into a video, and realized how much more efficient videos are: the cloth animation in mp4 form was only 156KB compared to 2.8MB! This shouldn't be surprised: video encodings are far more efficient than gifs (which I think just includes every frame as a separate image).

Now we have some other weirdness:

  • The video works well if it has been cached. If not, it's worse
  • Browsers stream videos with a "partial request". They request the content in ranges and get back a 206 partial content response with the data
  • The default caching for these requests differs across browsers. Chrome caches it by default; once cached I never see the request on server-side (I also never see it in devtools which is weird; unlike images which appear as a "disk cache" entry caches videos don't appear at all)
    • Safari doesn't cache it at all, apparently
    • I believe my server is sending the video without any caching headers at all, so that could likely be fixed by sending them
  • The worst part is what happens in the uncached scenario. This is important as it's all my first-time users
    • We have 10 <video> tags because of the slices, and 10 different range requests (that are all the exact same) are sent off
    • This is unlike images, where 10 different <img> tags that point to the same URL will only request the image once
    • This happens in both Chrome and Safari. It seems unlikely that adjusting cache headers would fix this (none of the requests have responses yet to read them)
  • So duplicate video tags will pull in data in separate requests. This means they will come back at different times, and each slice will start playing at different times. In the worse scenario of slower connections, this is very bad as the 10 slices are very out of sync
    • This never happened with gifs because duplicate <img> tags always seemed to only send one request for the same URL
  • I couldn't think of a solution to this, so let's keep looking

Use videos, but with a single MediaStream

I love how efficient videos are over gifs (156KB vs 2.8MB) so it's hard to give that up. How can we consolidate the requests and force the duplicate videos to always be in sync?

Apparently you can render a video into a canvas. You can do this by passing a video DOM element into the ctx.drawImage method. This will render the current frame of the video, so stick this in a requestAnimationLoop and it will continuously render the video:

const video = container.querySelector('video');
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);

const ctx = canvas.getContext('2d');

function draw() {
  if (!video.paused && !video.ended) {
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    ctx.drawImage(video, 0, 0);
    requestAnimationFrame(draw);
  }
}
draw();

This is very simplified code, but it illustrates the basic technique:

This worked great in Safari as well. Very promising.

Now we have a single <video> element loading and driving the video, and we should be able to render it into the 10 slices be creating 10 canvases.

After implemented this, this worked perfectly. Here's a video of it in action:

Some notes:

  • Notice how there is only a single request for the video for the entire page (even though we are slicing it up)
  • We wait for the video to be "playable" before animating the paper in. We didn't have this even using a gif: there's no even when the browser loads enough of a gif to start playing it. Now we can animate it in before the entire video loads
  • We no longer need a "poster" image, and the video plays while the paper animates in (which is important, if it's paused while animating in it breaks the illusion a lot)
    • We could still use a poster image for the video... however because we want the video to play while animating it doesn't really help
  • These are tiny videos, so they load fast. Even when throttled down to 3G speed (which is extremely slow) everything works pretty well
    • Gifs failed miserably in this scenario (due to being multiple megabytes)
  • Using canvases avoids any jitter and syncing issues due to multiple videos. Everything is perfectly smooth and seamless

Here's another WIP animation that is more exaggerated, showing how cool this effect is. Notice how the "video" plays but is able to be warped and mimic paper movement:

I'm extremely happy with how this turned out!

Safari optimizations

I use Safari as my main browser and I love it, quirks and all. One benefit of this is I'm aware of its quirks while developing, so I'll fix them during development which is far easier than fixing a large amount of problems later.

I noticed Safari was struggling with this technique when you have more paper elements with videos. With 8 on the page at once, the animations are extremely choppy. I was a bit worried about this, but after researching a bit (ok I'll confess: after chatting with AI) I discovered a technique that fixed the problem entirely.

We are rendering a <video> element into a canvas to capture the current frame. In Safari, apparently that decodes the video frame each time we call drawImage, and since we have 10 slices, we are doing that 10 times per video. Chrome must cache this decoding, so it only happens one per video regardless of the number of slices.

The fix is simple: create a "master" canvas where we render the <video> once into it, and then render that canvas into the 10 slices individually. It basically acts a "texture cache". Seems like you don't even need to mount this master canvas into the DOM:

let video = document.querySelector('video')

let masterCanvas = document.createElement('canvas');
let masterCtx = masterCanvas.getContext('2d', { alpha: false });

// All elements with the `.paper-slice` class name are canvases
let slices = document.querySelectorAll('.paper-slice');
let contexts = slices.map(slice => slice.getContext('2d', { alpha: false }));

function drawFrame() {
  masterCtx.drawImage(video, 0, 0);
  for (let i = 0; i < contexts.length; i++) {
    contexts[i].drawImage(masterCanvas, 0, 0);
  }
}