DEV Community

monkeymore studio
monkeymore studio

Posted on

Building an Animated QR Code Generator — Yes, It Actually Animates

A static QR code is fine. But what if yours could move? I built an animated QR code generator that embeds a GIF inside the QR code pattern itself—not a video playing on top, but the QR modules themselves changing frame by frame. It's wild, it works, and it's all client-side.

Why Animated QR Codes?

A QR code that moves catches eyes. That's the core value. When someone's scanning dozens of codes at a conference booth or flipping through event flyers, the one that animates stops them. It's not a gimmick—it genuinely increases scan rates because the QR stands out.

But here's what makes this approach special: it's not an overlay or a filter. The QR code modules themselves are the animation. Each frame is a valid QR code that scans normally. The animation is purely visual—the underlying data never changes.

This matters for practical reasons:

  • Works with any QR scanner (no custom app needed)
  • Print one frame for static use, use the GIF for digital
  • Same content, more attention

How It Works

The generator handles two cases: static artistic QR (image background) and full animated QR (GIF background):

The Static Artistic QR Path

When user uploads a static image (not GIF):

const handleGenerate = async () => {
  const baseOptions = {
    colorized: true,
    contrast: 1.0,
    brightness: 1.0,
    circleBorder: false,
    dotColor,
    width: qrWidth,
  };

  if (artImage) {
    const img = await loadImage(artImage);
    const result = await generateArtisticQR(line, { ...baseOptions, picture: img });
    results.push({ url: result.dataUrl, name: getFileName(line), isGif: false });
  } else {
    const result = await generateArtisticQR(line, baseOptions);
    results.push({ url: result.dataUrl, name: getFileName(line), isGif: false });
  }
};
Enter fullscreen mode Exit fullscreen mode

Pretty similar to the square and circle paths—the magic is in the generateArtisticQR function.

Frame Composition with Picture Background

The core magic happens in combineFrames:

function combineFrames(
  qrCanvas: HTMLCanvasElement,
  bgSource: HTMLImageElement | HTMLCanvasElement,
  ver: number,
  colorized: boolean,
  contrast: number,
  brightness: number
): HTMLCanvasElement {
  const qrSize = qrCanvas.width;
  const resultCanvas = document.createElement("canvas");
  resultCanvas.width = qrSize;
  resultCanvas.height = qrSize;
  const resultCtx = resultCanvas.getContext("2d")!;

  // Draw the QR pattern first
  resultCtx.drawImage(qrCanvas, 0, 0);

  // Create processed background from uploaded image
  const bgSize = qrSize - 24;  // Margin for finder patterns
  const procCanvas = document.createElement("canvas");
  procCanvas.width = bgSize;
  procCanvas.height = bgSize;
  const procCtx = procCanvas.getContext("2d")!;

  // Apply contrast and brightness filters
  procCtx.filter = `contrast(${contrast}) brightness(${brightness})`;

  // Scale image to contain within the QR area (not crop)
  const scale = Math.min(bgSize / bgW, bgSize / bgH);
  const drawW = bgW * scale;
  const drawH = bgH * scale;
  const offsetX = (bgSize - drawW) / 2;
  const offsetY = (bgSize - drawH) / 2;
  procCtx.drawImage(bgSource, offsetX, offsetY, drawW, drawH);

  // Now blend: copy background pixels into QR, skipping the timing patterns and alignment
  const qrData = resultCtx.getImageData(0, 0, qrSize, qrSize);
  const bgData = procCtx.getImageData(0, 0, bgSize, bgSize);

  for (let i = 0; i < bgSize; i++) {
    for (let j = 0; j < bgSize; j++) {
      // Skip finder patterns, alignment patterns, and timing patterns
      if (isTimingPattern(i, j) || isFinderPattern(i, j, qrSize) || isAlignmentPattern(i, j, ver)) {
        continue;
      }

      // Transparent pixels skip
      if (bgData.data[bgIdx + 3] === 0) continue;

      // Copy or grayscale based on colorized setting
      if (colorized) {
        qrData.data[qrIdx] = bgData.data[bgIdx];
        qrData.data[qrIdx + 1] = bgData.data[bgIdx + 1];
        qrData.data[qrIdx + 2] = bgData.data[bgIdx + 2];
      } else {
        const gray = 0.299 * bgData.data[bgIdx] + 
                   0.587 * bgData.data[bgIdx + 1] + 
                   0.114 * bgData.data[bgIdx + 2];
        const binary = gray > 128 ? 255 : 0;
        qrData.data[qrIdx] = binary;
        qrData.data[qrIdx + 1] = binary;
        qrData.data[qrIdx + 2] = binary;
      }
    }
  }

  resultCtx.putImageData(qrData, 0, 0);
  return resultCanvas;
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Contain, not cover: Uses scale-to-fit so the entire image shows (no cropping)
  • Preserves timing patterns: Skips the black-white-black timing lines between finders (critical for scannability)
  • Two modes: colorized keeps original colors; grayscale mode converts to pure black/white based on luminance threshold
  • Alignment preservation: For QR versions 2+, keeps the alignment pattern area clear

The Animated GIF Path

This is the fun part—processing frame-by-frame:

const handleGenerate = async () => {
  if (artImage && artFile && artFile.type === "image/gif") {
    const buffer = await artFile.arrayBuffer();
    const result = await generateAnimatedQR(line, buffer, baseOptions);
    results.push({ url: result.dataUrl, name: getFileName(line), isGif: true });
  }
};
Enter fullscreen mode Exit fullscreen mode

And the actual generation:

export async function generateAnimatedQR(
  words: string,
  gifBuffer: ArrayBuffer,
  options: ArtisticQROptions = {}
): Promise<GenerateResult> {
  const [qrVer, qrCanvas] = getQrCode(ver, level, words, dotColor, bgColor);
  const qrSize = qrCanvas.width;

  const reader = new GifReader(new Uint8Array(gifBuffer));
  const numFrames = reader.numFrames();

  const frameCanvases: HTMLCanvasElement[] = [];

  // Process each frame
  for (let i = 0; i < numFrames; i++) {
    // Handle GIF disposal (how previous frame clears)
    if (i > 0 && prevInfo.disposal === 2) {
      // Clear to transparent
      clearFrameBuffer(persistent);
    }

    // Decode current frame to buffer
    reader.decodeAndBlitFrameRGBA(i, persistent);

    // Create canvas from buffer
    const frameCanvas = document.createElement("canvas");
    frameCanvas.width = reader.width;
    frameCanvas.height = reader.height;
    const frameCtx = frameCanvas.getContext("2d")!;
    frameCtx.putImageData(new ImageData(persistent, reader.width, reader.height), 0, 0);

    // Combine with QR
    let resultCanvas = combineFrames(qrCanvas, frameCanvas, qrVer, colorized, contrast, brightness);

    // Scale up for output
    resultCanvas = scaleCanvas(resultCanvas, scale);

    frameCanvases.push(resultCanvas);
  }

  // Convert frames to GIF with optimized palette
  const gifData = encodeGif(frameCanvases, reader);
  return { dataUrl: gifData, isGif: true };
}
Enter fullscreen mode Exit fullscreen mode

GIF Disposal Handling

This is subtle but important. GIF frames don't always draw full pixels—they can have transparency indicating "don't change this pixel." The code handles disposal modes:

// Handle disposal from previous frame
if (i > 0) {
  const prevInfo = reader.frameInfo(i - 1);
  if (prevInfo.disposal === 2) {
    // Restore to background: clear to transparent
    // combineFrames will fall back to QR modules in those areas
    const x0 = Math.max(0, prevInfo.x);
    const y0 = Math.max(0, prevInfo.y);
    const x1 = Math.min(reader.width, prevInfo.x + prevInfo.width);
    const y1 = Math.min(reader.height, prevInfo.y + prevInfo.height);
    clearRegion(persistent, x0, y0, x1, y1);
  }
}
Enter fullscreen mode Exit fullscreen mode

For mode 2 (restore to background), the code clears those pixels back to transparent. The next frame will fill what it needs, and transparent parts fall back to showing the QR pattern beneath.

GIF Quality: Palette Optimization

GIF uses 256 colors max, so converting full-color frames needs care:

// Build global palette from downsampled frames
const PALETTE_SAMPLE_SIZE = 64;
const sampleCanvas = document.createElement("canvas");
sampleCanvas.width = PALETTE_SAMPLE_SIZE;
sampleCanvas.height = PALETTE_SAMPLE_SIZE;
const sampleCtx = sampleCanvas.getContext("2d")!;

// Sample all frames into a small canvas
const allPixels = new Uint8Array(PALETTE_SAMPLE_SIZE * PALETTE_SAMPLE_SIZE * 3 * numFrames);

for (const canvas of frameCanvases) {
  sampleCtx.drawImage(canvas, 0, 0, PALETTE_SAMPLE_SIZE, PALETTE_SAMPLE_SIZE);
  const imageData = sampleCtx.getImageData(0, 0, PALETTE_SAMPLE_SIZE, PALETTE_SAMPLE_SIZE);
  // Collect all pixel values...
}

// RunNeuQuant neural network quantizer
const quantizer = new NeuQuant(allPixels, 10);
quantizer.buildColormap();
const colorTab = quantizer.getColormap();

// Apply to all frames
for (const canvas of frameCanvases) {
  const imageData = ctx.getImageData(0, 0, outputSize, outputSize);
  const indexed = new Uint8Array(outputSize * outputSize);
  for (let i = 0; i < n; i++) {
    indexed[i] = quantizer.lookupRGB(
      imageData.data[i * 4],
      imageData.data[i * 4 + 1],
      imageData.data[i * 4 + 2]
    );
  }
  indexedFrames.push(indexed);
}

// Encode with omggif
const writer = new GifWriter(buf, outputSize, outputSize, {
  loop: 0,
  palette: globalPalette,
});

for (let i = 0; i < numFrames; i++) {
  const frameInfo = reader.frameInfo(i);
  const delay = frameInfo.delay || 10;
  writer.addFrame(0, 0, outputSize, outputSize, indexedFrames[i], {
    delay,
    disposal: 2,  // Clear after display
  });
}
Enter fullscreen mode Exit fullscreen mode

The trick: sample every frame at reduced resolution, build one palette good for all frames, then map each frame to those 256 colors. Uses NeuQuant (neural network quantization) for better results than simple median cut.

Output with omggif

Since gif.js is slow, omggif handles the encoding:

const buf = new Uint8Array(4 * 1024 * 1024);
const writer = new GifWriter(buf, outputSize, outputSize, {
  loop: 0,  // Infinite loop
  palette: globalPalette,
});

for (let i = 0; i < numFrames; i++) {
  const frameInfo = reader.frameInfo(i);
  const delay = frameInfo.delay || 10;
  writer.addFrame(0, 0, outputSize, outputSize, indexedFrames[i], {
    delay,
    disposal: 2,  // Start each frame fresh
  });
}

const endPos = writer.end();
const gifData = buf.subarray(0, endPos);
const blob = new Blob([gifData], { type: "image/gif" });
Enter fullscreen mode Exit fullscreen mode

Simple, reliable GIF encoding at decent speeds.

User Experience

The interface is intentionally simple:

// User uploads GIF
const [artImage, setArtImage] = useState<string | null>(null);
const [artFile, setArtFile] = useState<File | null>(null);

// Check if it's a GIF
if (artImage && artFile && artFile.type === "image/gif") {
  const buffer = await artFile.arrayBuffer();
  const result = await generateAnimatedQR(line, buffer, baseOptions);
  // Returns animated GIF
} else if (artImage) {
  // Returns static PNG with picture blend
}
Enter fullscreen mode Exit fullscreen mode

The code detects GIF vs static image and routes accordingly. User experience: upload, click generate, download—same for both.

Real-World Results

Animated QR codes work because they maintain scannability while being visually distinct. Test results:

Background Type Frame Count Size Scans on iPhone Scans on Android
Simple GIF 10 300KB Yes Yes
Complex GIF 30 800KB Yes Yes
Static blend 1 150KB Yes Yes

Complexity matters less than you'd think—scanners are robust. The real limit is visual: animations with high contrast work better than subtle ones.

Try It Yourself

Generate animated QR codes at Animated QR code generator.

Upload a GIF, enter your URL (one per line for batch), customize dot color and size, download. Everything runs in your browser—your URLs never go anywhere near a server.

Top comments (1)

Collapse
 
shaishav_patel_271fdcd61a profile image
Shaishav Patel

The GIF disposal mode handling is the part I'd have underestimated — mode 2 (restore to background) actively clearing regions between frames would break the finder patterns on the next frame if you're not accounting for it. That's a non-obvious constraint.

Curious what error correction level you settled on for the QR frames. Higher EC level (Q or H) gives you more tolerance for the image blending affecting module readability, but increases the density of the pattern — which could fight the visual animation effect. Did you find a sweet spot, or does the tool let users choose?

The NeuQuant approach for the 256-color palette is a nice choice — much better than naive median cut for GIF frames with smooth gradients.