Skip to content

Instantly share code, notes, and snippets.

@hon2a
Created December 5, 2025 09:32
Show Gist options
  • Select an option

  • Save hon2a/1a5018e234c34d110b21f7d37acd5133 to your computer and use it in GitHub Desktop.

Select an option

Save hon2a/1a5018e234c34d110b21f7d37acd5133 to your computer and use it in GitHub Desktop.
useFaviconBadge - dynamically render a badge over your web-app's favicon
import { useObjectMemo } from '@jllt/mui-extras';
import { useEffect, useMemo } from 'react';
/**
* Add notification badge (pill) to favicon in browser tab
* Inspired by https://stackoverflow.com/a/65720799 (heavily modified)
*/
interface BadgerOptions {
backgroundColor?: string;
color?: string;
size?: number; // scale relative to the favicon image size, 0..1
radius?: number; // border radius relative to badge size, 0..1 (0 = square corners)
// offset badge position from the edge of the favicon image
offset?: number; // offset relative to the favicon image size, 0..1
// fade favicon around the badge to make it legible
clipWidth?: number; // clip width relative to badge size, 0..1
clipOpacity?: number; // clip opacity, 0..1 (0 = no clip, 1 = full clip)
// font settings for badge value
fontFamily?: string;
fontSize?: number; // font size relative to badge size, 0..1
fontWeight?: number | string;
maxDigits?: number;
// badge position inside favicon
position?: 'n' | 'e' | 's' | 'w' | 'ne' | 'se' | 'sw' | 'nw';
src?: string; // favicon image URL (defaults to the current favicon)
}
interface ImageSetup {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D | null;
faviconEl: HTMLLinkElement;
faviconSize: number;
badgeSize: number;
offset: { x: number; y: number };
img: HTMLImageElement;
}
class Badger {
private options: Required<BadgerOptions>;
private images: Map<HTMLLinkElement, ImageSetup> = new Map();
private _value: number | boolean = 0;
constructor(options: BadgerOptions) {
this.options = Object.assign(
{
backgroundColor: '#f00',
color: '#fff',
size: 0.6,
offset: 0.05,
clipWidth: 0.1,
clipOpacity: 0.7,
fontFamily: 'Arial',
fontSize: 0.82,
fontWeight: 'bold',
maxDigits: 2,
position: 'ne',
radius: 1,
src: ''
},
options
);
}
private drawIcon({ ctx, img, faviconSize }: ImageSetup) {
if (!ctx) return;
ctx.clearRect(0, 0, faviconSize, faviconSize);
ctx.drawImage(img, 0, 0, faviconSize, faviconSize);
}
private drawBadgeShape(
ctx: CanvasRenderingContext2D,
{
xStart,
yStart,
xEnd,
yEnd,
radius
}: { xStart: number; yStart: number; xEnd: number; yEnd: number; radius: number }
) {
ctx.moveTo(xEnd - radius, yStart);
ctx.arc(xEnd - radius, yStart + radius, radius, -(Math.PI / 2), 0);
ctx.lineTo(xEnd, yEnd - radius);
ctx.arc(xEnd - radius, yEnd - radius, radius, 0, Math.PI / 2);
ctx.lineTo(xStart + radius, yEnd);
ctx.arc(xStart + radius, yEnd - radius, radius, Math.PI / 2, Math.PI);
ctx.lineTo(xStart, yStart + radius);
ctx.arc(xStart + radius, yStart + radius, radius, Math.PI, Math.PI * 1.5);
}
private drawClip({ ctx, badgeSize, offset }: ImageSetup) {
if (!ctx) return;
const clipSize = badgeSize * this.options.clipWidth;
if (clipSize <= 0) return;
const prevOperation = ctx.globalCompositeOperation;
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
this.drawBadgeShape(ctx, {
xStart: offset.x - clipSize,
yStart: offset.y - clipSize,
xEnd: offset.x + badgeSize + clipSize,
yEnd: offset.y + badgeSize + clipSize,
radius: (this.options.radius * (badgeSize + clipSize)) / 2
});
ctx.fillStyle = `rgba(0, 0, 0, ${this.options.clipOpacity})`;
ctx.fill();
ctx.closePath();
ctx.globalCompositeOperation = prevOperation;
}
private drawShape({ ctx, offset, badgeSize }: ImageSetup) {
if (!ctx) return;
ctx.beginPath();
this.drawBadgeShape(ctx, {
xStart: offset.x,
yStart: offset.y,
xEnd: offset.x + badgeSize,
yEnd: offset.y + badgeSize,
radius: (this.options.radius * badgeSize) / 2
});
ctx.fillStyle = this.options.backgroundColor;
ctx.fill();
ctx.closePath();
}
private drawValue({ ctx, badgeSize, offset }: ImageSetup) {
if (!ctx || typeof this._value !== 'number') return;
const margin = (badgeSize * (1 - this.options.fontSize)) / 2;
const fontSize = badgeSize * this.options.fontSize;
ctx.beginPath();
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.font = `${this.options.fontWeight} ${fontSize}px ${this.options.fontFamily}`;
ctx.fillStyle = this.options.color;
ctx.fillText(
String(Math.min(Math.pow(10, this.options.maxDigits) - 1, this._value)),
badgeSize / 2 + offset.x,
badgeSize / 2 + offset.y + margin
);
ctx.closePath();
}
private drawFavicon({ canvas, faviconEl }: ImageSetup) {
faviconEl.setAttribute('href', canvas.toDataURL());
}
private draw(opts: ImageSetup) {
this.drawIcon(opts);
if (this.value) {
this.drawClip(opts);
this.drawShape(opts);
this.drawValue(opts);
}
this.drawFavicon(opts);
}
private setup({
faviconEl,
img,
canvas,
ctx
}: {
faviconEl: HTMLLinkElement;
img: HTMLImageElement;
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
}) {
const faviconSize = img.naturalWidth;
const badgeSize = faviconSize * this.options.size;
canvas.width = faviconSize;
canvas.height = faviconSize;
const sideOffset = faviconSize * this.options.offset;
const sd = faviconSize - badgeSize - sideOffset;
const sd2 = sd / 2;
const offset = {
n: { x: sd2, y: sideOffset },
e: { x: sd, y: sd2 },
s: { x: sd2, y: sd },
w: { x: sideOffset, y: sd2 },
nw: { x: sideOffset, y: sideOffset },
ne: { x: sd, y: sideOffset },
sw: { x: sideOffset, y: sd },
se: { x: sd, y: sd }
}[this.options.position];
const setup: ImageSetup = { faviconEl, faviconSize, badgeSize, offset, img, canvas, ctx };
this.images.set(faviconEl, setup);
return setup;
}
// Public functions / methods:
update(options: BadgerOptions = {}) {
this.options = Object.assign(this.options, options);
const faviconElements = Array.from(document.querySelectorAll('link[rel$=icon]'));
Array.from(this.images.keys()).forEach((faviconEl) => {
if (!faviconElements.includes(faviconEl)) this.images.delete(faviconEl);
});
faviconElements.forEach((faviconEl) => {
if (!(faviconEl instanceof HTMLLinkElement)) return;
const existingSetup = this.images.get(faviconEl);
if (existingSetup) {
this.draw(existingSetup);
return;
}
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
img.addEventListener('load', () => {
this.draw(this.setup({ faviconEl, img, canvas, ctx }));
});
img.src = this.options.src || faviconEl.getAttribute('href') || '';
});
}
get value() {
return this._value;
}
set value(val: number | boolean) {
this._value = val;
this.update();
}
}
let singletonLock = false;
export const useFaviconBadge = (value: boolean | number, options: BadgerOptions = {}) => {
useEffect(() => {
if (singletonLock) throw new Error('`useFaviconBadge` can only be used once in the whole application!');
singletonLock = true;
return () => {
singletonLock = false;
};
}, []);
const optionsMemo = useObjectMemo(options);
const badger = useMemo(() => new Badger(options), []);
useEffect(() => {
badger.update(options);
}, [optionsMemo]);
useEffect(() => {
badger.value = value;
}, [value]);
useEffect(() => {
return () => {
badger.value = false;
};
}, []);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment