Scrub Animated GIF Frames with JavaScript

Implementing an interactive scrubber for an animated GIF requires decomposing the file into individual image frames and rendering them sequentially onto an HTML5 <canvas> element based on user input. Because standard HTML <img> tags do not provide an API to control GIF playback or access individual frames, developers must decode the raw binary data of the GIF, store the extracted frames in memory, and map an interactive UI element—such as a range slider or drag event listener—to the active frame index.

The Challenge with Native GIFs

Web browsers treat GIFs as static image elements with self-contained, automatic playback loops. There are no native methods like currentTime or seek() that exist for HTML5 video elements. To gain scrub control, you must treat the GIF as a sequence of discrete bitmap frames.

Step 1: Decode the GIF Frames

You must parse the binary format of the GIF to extract its metadata, color palettes, and individual frame graphic control extensions. While you can write a custom binary parser using JavaScript's DataView, standard practice involves using lightweight client-side libraries like gifuct-js or omggif.

Fetch the GIF as an ArrayBuffer and decode it:

import { parseGIF, decompressFrames } from 'gifuct-js';

async function loadGifFrames(url) {
  const response = await fetch(url);
  const buffer = await response.arrayBuffer();
  const gif = parseGIF(buffer);
  const frames = decompressFrames(gif, true); // true builds full patch data
  return frames;
}

Step 2: Pre-render Frames to Offscreen Canvases

GIF frames often utilize disposal methods and partial updates, meaning a single frame might only store the pixels that changed from the previous frame. To ensure smooth scrubbing in both forward and backward directions, composite each frame onto its own offscreen <canvas> during the initial load.

function prepareFrameCanvases(frames, width, height) {
  const renderedCanvases = [];
  const tempCanvas = document.createElement('canvas');
  const tempCtx = tempCanvas.getContext('2d');
  tempCanvas.width = width;
  tempCanvas.height = height;

  frames.forEach((frame) => {
    const frameCanvas = document.createElement('canvas');
    frameCanvas.width = width;
    frameCanvas.height = height;
    const frameCtx = frameCanvas.getContext('2d');

    // Convert pixel patch to ImageData
    const frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height);
    frameImageData.data.set(frame.patch);

    // Handle disposal and draw the current state
    tempCtx.putImageData(frameImageData, frame.dims.left, frame.dims.top);
    frameCtx.drawImage(tempCanvas, 0, 0);

    renderedCanvases.push(frameCanvas);
  });

  return renderedCanvases;
}

Step 3: Set Up the Canvas and Scrubber UI

Add an HTML <canvas> to display the active frame and an <input type="range"> element to serve as the timeline scrubber.

<canvas id="gif-viewer"></canvas>
<input type="range" id="gif-scrubber" min="0" value="0" step="1">

Step 4: Bind the Scrubber to the Display Canvas

Attach an input event listener to the slider to swap the visible canvas contents instantly whenever the user drags the scrubber.

async function initScrubber(gifUrl) {
  const frames = await loadGifFrames(gifUrl);
  const width = frames[0].dims.width;
  const height = frames[0].dims.height;

  const displayCanvas = document.getElementById('gif-viewer');
  const displayCtx = displayCanvas.getContext('2d');
  displayCanvas.width = width;
  displayCanvas.height = height;

  const scrubber = document.getElementById('gif-scrubber');
  scrubber.max = frames.length - 1;

  const frameCanvases = prepareFrameCanvases(frames, width, height);

  // Initial render
  displayCtx.drawImage(frameCanvases[0], 0, 0);

  // Update on scrub
  scrubber.addEventListener('input', (e) => {
    const frameIndex = parseInt(e.target.value, 10);
    displayCtx.clearRect(0, 0, width, height);
    displayCtx.drawImage(frameCanvases[frameIndex], 0, 0);
  });
}

Optional: Mouse or Touch Drag Controls

To allow users to drag directly across the image instead of using a slider, listen to pointer events on the canvas element. Calculate the horizontal delta relative to the canvas width and map the resulting percentage to the total frame count.