How it works

  1. Load your normal icon into an image.
  2. Draw it onto a small canvas, 32×32 is enough for a tab.
  3. Draw the badge, ring or spinner frame on top.
  4. Export with canvas.toDataURL('image/png') and set it as the href of a <link rel="icon">.
const img = new Image()
img.src = '/favicon-96x96.png'
await img.decode()

function setBadge(count) {
  const c = document.createElement('canvas')
  c.width = c.height = 32
  const ctx = c.getContext('2d')
  ctx.drawImage(img, 0, 0, 32, 32)
  if (count > 0) {
    ctx.fillStyle = '#e5484d'
    ctx.beginPath()
    ctx.arc(22, 10, 10, 0, 2 * Math.PI)
    ctx.fill()
    ctx.fillStyle = '#fff'
    ctx.font = '700 13px system-ui, sans-serif'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(count > 99 ? '99+' : String(count), 22, 11)
  }
  document.querySelector('link[rel~="icon"]').href = c.toDataURL('image/png')
}

If the page declares several icons, remove the others or replace them with one link while the dynamic icon is active. Otherwise the browser may keep showing the SVG it already chose.

Installed PWAs: use the Badging API

When a site is installed as an app, the operating system can show a count on its dock or taskbar icon. navigator.setAppBadge(n) sets it and navigator.clearAppBadge() removes it. Feature-detect it, and keep drawing the favicon badge for regular tabs:

if ('setAppBadge' in navigator) {
  count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge()
}

Progress rings and spinners

The same pipeline draws an arc for progress, useful for uploads, exports or builds. A spinner redraws the arc at a new angle on a timer. Keep animation modest: pause while document.hidden is true, and render a single static frame when the user prefers reduced motion.

Keep it accessible

  • A favicon is invisible to screen readers. Put the count in document.title as well, for example (3) Inbox.
  • Do not rely on color alone. A number or a clear shape reads better than a colored dot.
  • Respect prefers-reduced-motion for anything that animates.

Keep it cheap

  • Redraw only when the value changes, not on every message event.
  • Throttle rapid updates, such as a progress value that changes many times a second.
  • Stop timers and restore the original icon links when the state clears.

favicon-dynamic.js

favicon.zip can include favicon-dynamic.js in your package: a dependency-free module with your icon already embedded. Turn on the dynamic module option in the generator, or pass dynamic_module: true to the API or the MCP server.

import favicon from './favicon-dynamic.js'

favicon.badge(3)       // unread count, 99+ above 99
favicon.progress(0.42) // ring, 0 to 1
favicon.spinner()      // indeterminate
favicon.reset()        // back to the static icon

While active it swaps your icon links for a single generated PNG, and reset() puts the originals back. badge() also sets the app badge on installed PWAs where the Badging API exists. Animation pauses while the tab is hidden, and reduced-motion users get a static frame.

Questions

Can I animate a favicon?

Yes, by redrawing a canvas on a timer and swapping the icon URL. Browsers update the tab icon at a limited rate, so keep animations slow and simple, and pause them in hidden tabs.

Does the badge work in every browser?

Browsers differ in how, and whether, they show a changed tab icon, and Safari has historically been the least consistent. Treat the favicon badge as an enhancement, and keep the count in the page title.

What is the difference between a favicon badge and an app badge?

A favicon badge is drawn into the tab icon. An app badge, set with navigator.setAppBadge(), appears on an installed app's icon in the dock or taskbar.