Skip to main content

Animating favicons with Canvas and JavaScript

7 min read
Animating favicons with Canvas and JavaScript

In my previous article I played with animating the browser tab title using document.title. The natural follow-up: can you animate the favicon too? Yes. The trick is drawing to a hidden <canvas>, converting it to a data URL, and assigning it to the favicon's href. Inspired by this CSS-Tricks article by Giulio Mainardi, here are three variations.

#c7f0fe
#56d3c9

How it works

The technique has three steps that repeat on an interval:

  1. Draw something on a 32x32 canvas
  2. Convert the canvas to a PNG data URL with canvas.toDataURL('image/png')
  3. Set that URL as the favicon's href
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')

// Get or create the favicon link element
let link = document.querySelector('link[rel="icon"]')
if (!link) {
  link = document.createElement('link')
  link.rel = 'icon'
  document.head.appendChild(link)
}

// Draw something, then update the favicon
ctx.fillStyle = '#6366f1'
ctx.fillRect(0, 0, 32, 32)
link.href = canvas.toDataURL('image/png')

That's it. Whatever you can draw on a canvas, you can show as a favicon. Wrap it in a setInterval and you have animation.

1. Square loader

A gradient-stroked square draws itself around the favicon, one edge at a time. This is the approach from the CSS-Tricks article — a loading indicator that traces a square border.

const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
const link = document.querySelector('link[rel="icon"]')

let n = 0
const step = 32 / 25 // each side takes 25 frames

const interval = setInterval(() => {
  ctx.clearRect(0, 0, 32, 32)

  const gradient = ctx.createLinearGradient(0, 0, 32, 32)
  gradient.addColorStop(0, '#c7f0fe')
  gradient.addColorStop(1, '#56d3c9')
  ctx.strokeStyle = gradient
  ctx.lineWidth = 8

  ctx.beginPath()

  if (n <= 25) {
    ctx.moveTo(0, 0)
    ctx.lineTo(step * n, 0)
  } else if (n <= 50) {
    ctx.moveTo(0, 0); ctx.lineTo(32, 0)
    ctx.moveTo(32, 0); ctx.lineTo(32, step * (n - 25))
  } else if (n <= 75) {
    ctx.moveTo(0, 0); ctx.lineTo(32, 0)
    ctx.moveTo(32, 0); ctx.lineTo(32, 32)
    ctx.moveTo(32, 32); ctx.lineTo(32 - step * (n - 50), 32)
  } else {
    ctx.moveTo(0, 0); ctx.lineTo(32, 0)
    ctx.moveTo(32, 0); ctx.lineTo(32, 32)
    ctx.moveTo(32, 32); ctx.lineTo(0, 32)
    ctx.moveTo(0, 32); ctx.lineTo(0, 32 - step * (n - 75))
  }

  ctx.stroke()
  link.href = canvas.toDataURL('image/png')

  n++
  if (n > 100) n = 0
}, 60)

The math distributes 100 frames across 4 sides of the square: 25 frames per edge. Each frame clears and redraws everything from scratch, which gives the progressive drawing effect.

2. Progress pie

A circular progress indicator that fills like a pie chart. Useful for showing upload or download progress.

const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
const link = document.querySelector('link[rel="icon"]')

function drawProgress(percent) {
  ctx.clearRect(0, 0, 32, 32)

  // Background circle
  ctx.beginPath()
  ctx.arc(16, 16, 12, 0, Math.PI * 2)
  ctx.fillStyle = '#e4e4e7'
  ctx.fill()

  // Progress arc (pie slice)
  const startAngle = -Math.PI / 2
  const endAngle = startAngle + (Math.PI * 2 * percent) / 100
  ctx.beginPath()
  ctx.moveTo(16, 16)
  ctx.arc(16, 16, 12, startAngle, endAngle)
  ctx.closePath()
  ctx.fillStyle = '#6366f1'
  ctx.fill()

  // Center hole (donut effect)
  ctx.beginPath()
  ctx.arc(16, 16, 5, 0, Math.PI * 2)
  ctx.fillStyle = '#e4e4e7'
  ctx.fill()

  link.href = canvas.toDataURL('image/png')
}

The startAngle at -Math.PI / 2 makes progress start from the top (12 o'clock position) instead of the right side, which is the canvas default. The center cutout turns it from a pie chart into a donut, which reads better at favicon size.

3. Notification pulse

A pulsing circle that grows and fades — a subtle way to signal that something needs attention.

const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
const link = document.querySelector('link[rel="icon"]')

let t = 0
setInterval(() => {
  ctx.clearRect(0, 0, 32, 32)

  const scale = 0.5 + 0.5 * Math.sin(t * 0.1)
  const radius = 4 + scale * 10
  const alpha = 0.3 + 0.7 * scale

  // Outer glow
  ctx.beginPath()
  ctx.arc(16, 16, radius + 3, 0, Math.PI * 2)
  ctx.fillStyle = `rgba(239, 68, 68, ${alpha * 0.3})`
  ctx.fill()

  // Main circle
  ctx.beginPath()
  ctx.arc(16, 16, radius, 0, Math.PI * 2)
  ctx.fillStyle = `rgba(239, 68, 68, ${alpha})`
  ctx.fill()

  link.href = canvas.toDataURL('image/png')
  t++
}, 40)

Math.sin(t * 0.1) gives a smooth oscillation. The 0.1 controls how fast it pulses — smaller values are slower and more subtle. The outer glow circle with lower opacity creates a soft halo effect.

As React hooks

Each animation wraps into a hook that manages the canvas lifecycle and cleans up on unmount:

function useAnimatedFavicon(draw: (ctx: CanvasRenderingContext2D, frame: number) => void, speed = 60) {
  useEffect(() => {
    const canvas = document.createElement('canvas')
    canvas.width = 32
    canvas.height = 32
    const ctx = canvas.getContext('2d')!

    let link = document.querySelector('link[rel="icon"]') as HTMLLinkElement
    const originalHref = link?.href

    if (!link) {
      link = document.createElement('link')
      link.rel = 'icon'
      document.head.appendChild(link)
    }

    let frame = 0
    const interval = setInterval(() => {
      ctx.clearRect(0, 0, 32, 32)
      draw(ctx, frame)
      link.href = canvas.toDataURL('image/png')
      frame++
    }, speed)

    return () => {
      clearInterval(interval)
      if (originalHref) link.href = originalHref
    }
  }, [draw, speed])
}

Usage is straightforward — pass a draw function and the hook handles the rest:

useAnimatedFavicon((ctx, frame) => {
  const scale = 0.5 + 0.5 * Math.sin(frame * 0.1)
  ctx.beginPath()
  ctx.arc(16, 16, 4 + scale * 10, 0, Math.PI * 2)
  ctx.fillStyle = `rgba(239, 68, 68, ${0.3 + 0.7 * scale})`
  ctx.fill()
}, 40)

Things to keep in mind

Performance. canvas.toDataURL() is not free. It encodes the canvas pixels to a PNG base64 string on every frame. At 60ms intervals on a 32x32 canvas this is negligible, but don't go wild with resolution or frame rate. 32x32 is the standard favicon size and there's no reason to go higher.

Browser throttling. Same as with document.title animations — background tabs throttle setInterval to once per second. Your favicon animation will slow down when the tab isn't visible. This is actually fine for most use cases.

Cleanup. Always restore the original favicon when the animation ends. If you're using React, the cleanup function in useEffect is the natural place for this. Otherwise, save the original href before you start and restore it when you stop.

Browser support. Dynamic favicons work in Chrome, Firefox, and Edge. Safari has historically been inconsistent with data URL favicons — test if Safari support matters to you.

link[rel="icon"] vs link[rel="shortcut icon"]. Modern browsers only need rel="icon". The shortcut variant is legacy. If your HTML has both, update the one the browser actually uses, or update both to be safe.

When to use these

The square loader is good for indicating background work — file uploads, data syncing, long API calls. The progress pie is the most informative: it maps directly to a completion percentage, which is useful for downloads or multi-step processes. The notification pulse works as an attention signal — new messages, completed tasks, or anything where you want to draw the user's eye to a specific tab.

All three share the same core idea: a hidden canvas, a setInterval, and toDataURL. Once you have that pattern, any canvas drawing becomes a potential favicon animation.


More to read

How to invoice Apple for App Store proceeds

Apple hands you a CSV, not an invoice. Drop your App Store Connect financial report below and instantly generate the self-issued invoice and itemized report your accountant needs — one PDF, entirely in your browser.

How to check if an iOS app is installed in Expo and React Native

Use Linking.canOpenURL to detect if another app is installed on iOS in Expo. Covers Expo config plugin setup, URL scheme configuration, CNG workflow, and a hook to conditionally show a deep link button.