2026-04-25

Анимированный фавикон через Canvas и Web Worker

Фавикон — маленькая деталь, которую пользователь видит всегда, даже когда вкладка свёрнута. Это делает его неожиданно удобным местом для передачи информации: количество непрочитанных сообщений, статус фоновой задачи, визуальный «пинг» при получении уведомления.

Юзкейсы

  • Уведомления — Gmail меняет иконку при новом письме; то же можно сделать для чатов, дашбордов, очередей задач.
  • Индикатор фоновой активности — загрузка файла, рендер, синхронизация: пульсирующая иконка говорит «я работаю».
  • Идентификация вкладки — в приложениях, где пользователь открывает несколько вкладок одного сайта (разные аккаунты, разные документы), анимация помогает найти нужную.
  • Пасхалки — просто потому что можно.

Базовый механизм

Браузер читает фавикон из <link rel="icon">. Если поменять href этого элемента — иконка обновится. Canvas умеет экспортировать содержимое в data URL. Склеиваем:

const canvas = document.createElement('canvas')
canvas.width = canvas.height = 64
const ctx = canvas.getContext('2d')

const link = document.querySelector('link[rel="icon"]')

function drawFrame(t) {
  ctx.clearRect(0, 0, 64, 64)
  // ... рисуем что-то в зависимости от t ...
  link.href = canvas.toDataURL('image/png')
}

function loop(ts) {
  drawFrame(ts / 1000)
  requestAnimationFrame(loop)
}
requestAnimationFrame(loop)

toDataURL на холсте 64×64 — дешёвая операция, но гнать её в каждом кадре всё равно избыточно. Достаточно ~20 fps:

let lastUpdate = 0

function loop(ts) {
  if (ts - lastUpdate > 50) {
    drawFrame(ts / 1000)
    link.href = canvas.toDataURL('image/png')
    lastUpdate = ts
  }
  requestAnimationFrame(loop)
}

Проблема: Chrome игнорирует не-последний фавикон

Если в <head> уже есть один или несколько <link rel="icon">, Chrome не гарантирует, что возьмёт именно тот, href которого вы меняете. На практике побеждает последний в DOM.

Chrome не переподцепляет фавикон динамически: добавление нового <link> в конец <head> при уже существующих не даёт эффекта — браузер держится за тот, который подцепил первым. Единственный надёжный способ — удалить все существующие ссылки перед добавлением динамической, а при остановке — восстановить статичную.

// Сохраняем оригиналы до того, как трогаем DOM
const staticLinks = [...document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')]

const dynLink = document.createElement('link')
dynLink.rel = 'icon'
dynLink.type = 'image/png'

function start() {
  staticLinks.forEach(el => el.remove())
  document.head.appendChild(dynLink)
}

function stop() {
  dynLink.remove()
  staticLinks.forEach(el => document.head.appendChild(el))
}

Прозрачный фон

Canvas по умолчанию прозрачен, но некоторые техники рисования требуют белого фона — например, globalCompositeOperation = 'multiply' для смешения цветовых слоёв. После отрисовки белые пиксели можно занулить через getImageData:

function punchOutWhite(ctx, w, h) {
  const img = ctx.getImageData(0, 0, w, h)
  const d = img.data
  for (let i = 0; i < d.length; i += 4) {
    if (d[i] > 250 && d[i+1] > 250 && d[i+2] > 250) d[i+3] = 0
  }
  ctx.putImageData(img, 0, 0)
}

Порог 250 подходит для большинства случаев: чистый белый фон убирается, цветные пиксели остаются.

Web Worker для фоновых вкладок

requestAnimationFrame замораживается, когда вкладка уходит в фон. Именно тогда анимация нужна больше всего — пользователь переключился на другую вкладку и должен заметить изменение.

setInterval в основном потоке тоже ненадёжен: Chrome throttle-ит его до ~1 секунды для фоновых вкладок.

Решение — Web Worker. Воркер живёт независимо от видимости страницы, его таймеры не throttle-ятся так же агрессивно:

const workerSrc = `
  setInterval(() => {
    const t = Date.now() / 1000
    postMessage({
      x: Math.sin(t * 2) * 10,
      y: Math.cos(t * 1.5) * 10,
    })
  }, 50)
`

const blob = new Blob([workerSrc], { type: 'application/javascript' })
const worker = new Worker(URL.createObjectURL(blob))

worker.onmessage = ({ data }) => {
  // рисуем и обновляем фавикон
  draw(data.x, data.y)
  dynLink.href = canvas.toDataURL('image/png')
}

Воркер вычисляет координаты/состояние и присылает их в основной поток, который рисует на canvas. Canvas в воркере недоступен без OffscreenCanvas — для фавикона это излишне.

Blob URL можно отозвать сразу после создания воркера — скрипт уже загружен:

const url = URL.createObjectURL(blob)
const worker = new Worker(url)
URL.revokeObjectURL(url) // OK, воркер работает дальше

Запуск только на фоновой вкладке

Анимировать фавикон, когда страница и так видна, — бессмысленно. document.visibilitychange сообщает о переходе вкладки в фон и обратно:

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    start() // добавляем dynLink, воркер начинает обновлять href
  } else {
    stop()  // убираем dynLink, браузер откатывается к статичному SVG
  }
})

Воркер при этом можно не останавливать — пусть считает в фоне. Просто игнорируйте его сообщения, когда dynLink вне DOM:

worker.onmessage = ({ data }) => {
  if (!document.hidden) return
  draw(data)
  dynLink.href = canvas.toDataURL('image/png')
}

Итог

// Полная схема
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 64
const ctx = canvas.getContext('2d')

const dynLink = document.createElement('link')
dynLink.rel = 'icon'
dynLink.type = 'image/png'

const worker = new Worker(/* blob с setInterval + postMessage */)

worker.onmessage = ({ data }) => {
  if (!document.hidden) return
  // ctx.* — рисуем кадр
  dynLink.href = canvas.toDataURL('image/png')
}

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    start() // удаляем статику, добавляем dynLink
  } else {
    stop()  // убираем dynLink, восстанавливаем статику
  }
})

Пять строк подключения, остальное — логика анимации. Фавикон заработает в любом фреймворке и без него.

© 2026 Илья Элланский