Created
December 5, 2025 09:32
-
-
Save hon2a/1a5018e234c34d110b21f7d37acd5133 to your computer and use it in GitHub Desktop.
useFaviconBadge - dynamically render a badge over your web-app's favicon
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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