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.
How it works
The technique has three steps that repeat on an interval:
- Draw something on a 32x32 canvas
- Convert the canvas to a PNG data URL with
canvas.toDataURL('image/png') - 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.