export interface FaviconBadgeOptions {
/**
* Badge fill color. Default Facebook-ish red.
*/
badgeColor?: string;
/**
* Text/number color. Default white.
*/
textColor?: string;
/**
* `true` → paint the count inside the badge (caps at 99+).
* `false` → flat dot. Default `false` — Facebook-style restraint.
*/
showCount?: boolean;
/**
* Optional explicit base favicon URL. If omitted, we read the current
* `` href; if there is none, we paint onto a blank
* canvas so something useful still appears on the tab.
*/
baseUrl?: string;
/**
* Canvas size in CSS px. 32 is the standard favicon hi-DPI size.
*/
size?: number;
/**
* Pulse the badge by alternating between the badged frame and the
* bare base favicon. Default `true` — periphery-vision cue that
* makes the dot findable in a tab bar of dozens of icons. Set
* `false` for a static dot (quieter; matches enterprise tastes).
*/
pulse?: boolean;
/**
* Milliseconds the badge stays visible per cycle. Default 600.
*/
pulseOnMs?: number;
/**
* Milliseconds the badge is hidden per cycle. Default 400.
* Asymmetric on/off (default 600/400) gives a Slack/FB-style
* heartbeat — the badge is the dominant state, the gap is brief.
*/
pulseOffMs?: number;
}
interface BadgeHandle {
set(count: number): void;
clear(): void;
}
const LINK_REL_VARIANTS = ['icon', 'shortcut icon'] as const;
const MANAGED_ATTR = 'data-chat-favicon-badge';
/**
* Paints a small badge over the page's favicon using a hidden canvas
* and swaps the resulting data URL into ``. SSR-safe.
*
* Notes / gotchas (encoded as invariants):
* - SVG favicons can't be rastered via `` reliably across
* browsers without inlining. We attempt anyway; on failure we paint
* a blank background + the badge so the tab still signals unread.
* - We never mutate the host's original ``. We add a managed
* `` ahead of it; on clear
* we remove that managed node. This is the cleanest way to handle
* hosts that swap their own favicons (e.g. Next.js dynamic icons).
* - Cross-origin favicons need CORS to draw; if drawing throws we
* fall back to a CORS-less canvas (badge only, no base image).
*/
export function createFaviconBadge(opts: FaviconBadgeOptions = {}): BadgeHandle {
if (typeof document === 'undefined') {
return { set() {}, clear() {} };
}
const badgeColor = opts.badgeColor ?? '#ef4444';
const textColor = opts.textColor ?? '#ffffff';
const showCount = opts.showCount ?? false;
const size = opts.size ?? 32;
const pulse = opts.pulse ?? true;
const pulseOnMs = opts.pulseOnMs ?? 600;
const pulseOffMs = opts.pulseOffMs ?? 400;
let baseImg: HTMLImageElement | null = null;
let baseLoaded = false;
let lastCount = -1;
// Pulse animation state.
let frameOn: string | null = null; // badged frame data URL
let frameOff: string | null = null; // bare base frame data URL (or empty)
let pulseTimer: ReturnType | null = null;
let pulsePhase: 'on' | 'off' = 'on';
const detectBaseUrl = (): string | null => {
if (opts.baseUrl) return opts.baseUrl;
for (const rel of LINK_REL_VARIANTS) {
const link = document.querySelector(
`link[rel="${rel}"]:not([${MANAGED_ATTR}])`,
);
if (link?.href) return link.href;
}
return null;
};
const loadBase = () => {
if (baseImg) return;
const url = detectBaseUrl();
if (!url) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
baseLoaded = true;
// If a `set()` came in before load completed, re-render now.
if (lastCount >= 0) renderFrames(lastCount);
};
img.onerror = () => {
// Network/CORS failure: leave baseLoaded=false; we'll paint badge
// on a blank canvas.
baseLoaded = false;
};
img.src = url;
baseImg = img;
};
const removeManagedLinks = () => {
document
.querySelectorAll(`link[${MANAGED_ATTR}]`)
.forEach((node) => node.remove());
};
const applyDataUrl = (dataUrl: string) => {
removeManagedLinks();
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.href = dataUrl;
link.setAttribute(MANAGED_ATTR, 'true');
document.head.appendChild(link);
};
// Paint the off-phase frame. If we have a usable base favicon, this
// is the bare base; otherwise a transparent canvas. Returning a
// transparent frame (rather than `null` + removing the link) is
// load-bearing: Chrome only re-rasters the tab favicon when the
// managed ``'s href *changes*, so off-phase MUST swap to a
// different data URL — not vanish — for the pulse to be visible.
const renderOffFrame = (): string | null => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
if (baseImg && baseLoaded) {
try {
ctx.drawImage(baseImg, 0, 0, size, size);
} catch {
// Tainted canvas → fall through to the empty frame.
ctx.clearRect(0, 0, size, size);
}
}
// If no base, the canvas stays fully transparent — Chrome falls
// back to its default tab glyph, which still gives a visible
// on/off contrast against the red badge.
try {
return canvas.toDataURL('image/png');
} catch {
return null;
}
};
// Paint the badged frame. Reused for both the static (no-pulse) case
// and the `on` phase of the pulse loop.
const renderBadged = (count: number): string | null => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
if (baseImg && baseLoaded) {
try {
ctx.drawImage(baseImg, 0, 0, size, size);
} catch {
ctx.clearRect(0, 0, size, size);
}
}
const r = size * 0.28;
const cx = size - r - size * 0.04;
const cy = size - r - size * 0.04;
ctx.fillStyle = badgeColor;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
if (showCount) {
const label = count > 99 ? '99+' : String(count);
ctx.fillStyle = textColor;
const fontSize = label.length >= 3 ? r * 0.85 : r * 1.25;
ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, cx, cy + fontSize * 0.06);
}
try {
return canvas.toDataURL('image/png');
} catch {
// Tainted-canvas fallback: badge only, no base.
const fb = document.createElement('canvas');
fb.width = size;
fb.height = size;
const fctx = fb.getContext('2d');
if (!fctx) return null;
fctx.fillStyle = badgeColor;
fctx.beginPath();
fctx.arc(cx, cy, r, 0, Math.PI * 2);
fctx.fill();
return fb.toDataURL('image/png');
}
};
const stopPulse = () => {
if (pulseTimer !== null) {
clearTimeout(pulseTimer);
pulseTimer = null;
}
};
const tickPulse = () => {
if (frameOn === null) return;
// Always swap the managed 's href between two distinct data
// URLs — Chrome / Safari skip the redraw if the href is identical
// to what was just set, so removing-and-readding the node isn't
// enough on its own (`renderOffFrame` guarantees a non-null off
// frame whenever frameOn exists).
if (pulsePhase === 'on') {
applyDataUrl(frameOn);
pulsePhase = 'off';
pulseTimer = setTimeout(tickPulse, pulseOnMs);
} else {
applyDataUrl(frameOff ?? frameOn);
pulsePhase = 'on';
pulseTimer = setTimeout(tickPulse, pulseOffMs);
}
};
const renderFrames = (count: number) => {
frameOn = renderBadged(count);
frameOff = renderOffFrame();
if (frameOn === null) return;
if (!pulse) {
applyDataUrl(frameOn);
return;
}
// (Re-)start the pulse loop. If a previous loop was running we
// tear it down first so the cadence doesn't double up.
stopPulse();
pulsePhase = 'on';
tickPulse();
};
return {
set(count) {
lastCount = count;
loadBase();
// If base not loaded yet, the onload handler will call
// renderFrames(lastCount) once it resolves.
renderFrames(count);
},
clear() {
lastCount = -1;
stopPulse();
frameOn = null;
frameOff = null;
removeManagedLinks();
},
};
}