AnimatedEmojiesAnimatedEmojies

How to Create an Animated Favicon with JavaScript (That Actually Works)

A

Alex Rivera

April 5, 2026 · 5 min read

tutorialjavascriptfaviconanimationweb developmentgifuct-js

You've seen sites with animated favicons — that little icon in the browser tab dancing, spinning, or cycling through frames. But if you've tried to just set a GIF as your favicon, you've discovered the painful truth: Chrome doesn't animate GIF favicons. emoji

Firefox does. Safari doesn't. Chrome shows the first frame and calls it a day.

Here's how to make it work everywhere. emoji

emoji What You'll Build

A favicon that plays animated GIF frames by decoding them with gifuct-js, rendering to canvas, and swapping the favicon href at 60fps. Works in Chrome, Firefox, and Edge.

emoji The Problem

Try this and see what happens:

<link rel="icon" type="image/gif" href="/animated.gif" />

Firefox: Animates perfectly. emoji
Chrome/Edge: Shows first frame only. Static. emoji
Safari: Shows first frame. Sometimes shows nothing.

The reason? Chrome intentionally doesn't animate favicons to save CPU/battery. There's an open Chromium issue about this from years ago. It's not a bug — it's a deliberate choice.

emoji The Solution: Canvas + gifuct-js

The trick is to:

  1. Decode the GIF into individual frames using gifuct-js
  2. Render each frame to an offscreen canvas
  3. Convert the canvas to a PNG data URL
  4. Set it as the favicon href
  5. Repeat at the GIF's native frame rate

Step 1: Install gifuct-js

npm install gifuct-js

Step 2: The Component (React/Next.js)

"use client";

import { useEffect, useRef } from "react";
import { parseGIF, decompressFrames } from "gifuct-js";

export function AnimatedFavicon() {
  const frameIdx = useRef(0);
  const timerRef = useRef(undefined);

  useEffect(() => {
    // Create favicon link element
    const link = document.createElement("link");
    link.rel = "icon";
    link.type = "image/png";
    document.head.appendChild(link);

    // Canvas for rendering frames
    const canvas = document.createElement("canvas");
    canvas.width = 32;
    canvas.height = 32;
    const ctx = canvas.getContext("2d");

    let frames = [];
    let delays = [];
    let stopped = false;

    // Decode the GIF
    fetch("/favicon-animated.gif")
      .then(r => r.arrayBuffer())
      .then(buff => {
        const gif = parseGIF(buff);
        const decoded = decompressFrames(gif, true);

        // Composite each frame
        const temp = document.createElement("canvas");
        temp.width = 32; temp.height = 32;
        const tCtx = temp.getContext("2d");

        for (const frame of decoded) {
          const patch = new ImageData(
            new Uint8ClampedArray(frame.patch),
            frame.dims.width, frame.dims.height
          );
          if (frame.disposalType === 2)
            tCtx.clearRect(0, 0, 32, 32);

          const p = document.createElement("canvas");
          p.width = frame.dims.width;
          p.height = frame.dims.height;
          p.getContext("2d").putImageData(patch, 0, 0);
          tCtx.drawImage(p, frame.dims.left, frame.dims.top);

          frames.push(tCtx.getImageData(0, 0, 32, 32));
          delays.push(frame.delay || 80);
        }

        if (!stopped) drawFrame();
      });

    function drawFrame() {
      if (stopped || !frames.length) return;
      ctx.putImageData(frames[frameIdx.current], 0, 0);
      link.href = canvas.toDataURL("image/png");
      const delay = delays[frameIdx.current] || 80;
      frameIdx.current = (frameIdx.current + 1) % frames.length;
      timerRef.current = setTimeout(drawFrame, delay);
    }

    return () => { stopped = true; clearTimeout(timerRef.current); link.remove(); };
  }, []);

  return null;
}

Step 3: Use It

// In your layout.tsx or _app.tsx
import { AnimatedFavicon } from "./AnimatedFavicon";

export default function Layout({ children }) {
  return (
    <html>
      <body>
        <AnimatedFavicon />
        {children}
      </body>
    </html>
  );
}

emoji How It Works Under the Hood

1️⃣

Fetch GIF

Download as ArrayBuffer

2️⃣

Decode Frames

gifuct-js extracts each frame + delay

3️⃣

Composite

Handle disposal, draw patches onto canvas

4️⃣

Swap Favicon

canvas.toDataURL → link.href on timer

emoji Why not just use drawImage on an <img>?

You might think: load the GIF in an <img>, draw it to canvas each frame. The problem is Chrome's drawImage() only captures the current displayed frame of a GIF — and Chrome doesn't advance frames for offscreen images. You get a static image. The gifuct-js approach decodes all frames upfront, bypassing this limitation entirely.

emoji Bonus: Rotating Multiple Animated Emoji

Want to cycle through different animated emoji in your favicon? Create a combined GIF with all emoji playing in sequence:

# Python — combine multiple GIFs into one favicon
from PIL import Image

emojis = ['party-popper', 'fire', 'rocket', 'heart']
frames, durations = [], []

for name in emojis:
    with Image.open(f'{name}.gif') as gif:
        for i in range(gif.n_frames):
            gif.seek(i)
            frame = gif.convert('RGBA').resize((32, 32))
            frames.append(frame.copy())
            durations.append(gif.info.get('duration', 80))

frames[0].save('favicon-animated.gif',
    save_all=True, append_images=frames[1:],
    duration=durations, loop=0, disposal=2)

This creates a single GIF that plays through each emoji in sequence. The JavaScript component above will handle the rest — it doesn't care if it's one emoji or twenty. emoji

emoji Performance Tip

Keep your favicon GIF at 32×32 pixels. Larger sizes mean larger data URLs on every frame swap, which can cause jank. 32px is the standard favicon size and Chrome/Firefox will display it at 16px anyway.

emoji Browser Support

Browser Native GIF Favicon With gifuct-js
Chrome❌ Static only✅ Animated
Firefox✅ Animated✅ Animated
Edge❌ Static only✅ Animated
Safari❌ Static only⚠️ Updates slowly

emoji Try It Live

This exact technique powers the animated favicon on AnimatedEmojies. Our favicon cycles through 20 different animated emoji — party popper, fire, rocket, heart, and more — all decoded and rendered frame-by-frame in the browser tab.

See It In Action

Check the browser tab!

gifuct-js on npm

GIF frame decoder


Sources & References