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 });
}
};
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;
}
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:
colorizedkeeps 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 });
}
};
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 };
}
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);
}
}
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
});
}
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" });
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
}
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)
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.