Tiny, dependency-free favicon state indicators for modern web apps.
magic-favicon turns your tab icon into a compact status surface for progress, notifications, health states, and activity animation without mutating the page title.
Current size snapshot: ~2.5KB gzipped (core ESM build measured on February 25, 2026).
- Tiny runtime footprint (optimized for strict size budgets)
- Zero runtime dependencies
- TypeScript-first API
- Modern outputs: ESM, CJS, UMD
- Works in background tabs with worker-backed ticker fallback logic
- High-DPI aware canvas rendering for sharper badge text/icons
| Capability | Method | Notes |
|---|---|---|
| Progress bar | progress(value) |
Horizontal bottom bar, clamped 0..100 |
| Pie progress | pie(value) |
Circular ring progress, clamped 0..100 |
| Badge count | badge(count) |
Auto formats to 99+ |
| Status indicator | status(kindOrColor) |
Built-in states or custom color string |
| Pulse animation | pulse(options) |
Soft radial activity pulse |
| Spin animation | spin(options) |
Indeterminate ring spinner |
| Reset | reset() / clear() |
Restores original favicon |
| Global defaults | setDefaults(options) |
Reuse shared config across calls |
npm install magic-faviconimport favicon from "magic-favicon";
favicon.progress(35);
favicon.pie(72);
favicon.badge(5);
favicon.status("warning");
favicon.status("#7c3aed"); // custom status color
favicon.pulse();
favicon.spin();
favicon.reset();
favicon.badge(8, { sizeRatio: 1.25 });All framework integrations follow the same rule: call magic-favicon only on the client.
import { useEffect } from "react";
import favicon from "magic-favicon";
export function UploadTabStatus({ progress }: { progress: number }) {
useEffect(() => {
favicon.progress(progress, { preserveBase: true });
return () => favicon.reset();
}, [progress]);
return null;
}"use client";
import { useEffect } from "react";
import favicon from "magic-favicon";
export default function RealtimeIndicator() {
useEffect(() => {
favicon.spin({ color: "#f59e0b" });
return () => favicon.reset();
}, []);
return null;
}import { Component, DestroyRef, effect, inject, PLATFORM_ID, signal } from "@angular/core";
import { isPlatformBrowser } from "@angular/common";
import favicon from "magic-favicon";
@Component({
selector: "app-upload-status",
standalone: true,
template: `{{ progress() }}%`
})
export class UploadStatusComponent {
progress = signal(0);
private platformId = inject(PLATFORM_ID);
private destroyRef = inject(DestroyRef);
constructor() {
if (!isPlatformBrowser(this.platformId)) return;
effect(() => {
favicon.progress(this.progress());
});
this.destroyRef.onDestroy(() => favicon.reset());
}
}<script setup lang="ts">
import { onMounted, onBeforeUnmount } from "vue";
import favicon from "magic-favicon";
onMounted(() => favicon.badge(6, { preserveBase: true }));
onBeforeUnmount(() => favicon.reset());
</script><script setup lang="ts">
import { onMounted, onBeforeUnmount } from "vue";
import favicon from "magic-favicon";
onMounted(() => favicon.status("success"));
onBeforeUnmount(() => favicon.reset());
</script><script lang="ts">
import { onMount } from "svelte";
import favicon from "magic-favicon";
onMount(() => {
favicon.pulse({ preserveBase: true });
return () => favicon.reset();
});
</script>Set global defaults merged into subsequent method calls.
favicon.setDefaults({
preserveBase: true,
color: "#0ea5e9",
trackColor: "rgba(0,0,0,0.25)",
lineWidth: 4
});Options:
color?: stringtrackColor?: stringheightRatio?: number(0.1..0.6)sizeRatio?: number(0.4..1.6)preserveBase?: boolean
Options:
color?: stringtrackColor?: stringlineWidth?: number(2..10)sizeRatio?: number(0.4..1.6)preserveBase?: boolean
Options:
bgColor?: stringtextColor?: stringposition?: 'tr' | 'tl' | 'br' | 'bl'sizeRatio?: number(0.4..1.6)preserveBase?: boolean
Behavior:
count <= 0triggers resetcount > 99displays99+
kindOrColor can be:
'success''warning''error'- any CSS color string (custom)
Options:
successColor?: stringwarningColor?: stringerrorColor?: stringshape?: 'dot' | 'ring' | 'square'ringWidth?: numbersizeRatio?: number(0.4..1.6)preserveBase?: boolean
Options:
color?: stringperiodMs?: number(min300)tickMs?: number(min16)lineWidth?: number(spinring width)sizeRatio?: number(0.4..1.6)preserveBase?: boolean
reset()restores original favicon attributes.clear()is an alias forreset().destroy()stops running animations.
import { createMagicFavicon } from "magic-favicon";
const faviconA = createMagicFavicon();
const faviconB = createMagicFavicon();favicon.progress(0);
upload.on("progress", (p: number) => {
favicon.progress(p);
});
upload.on("done", () => {
favicon.status("success");
});favicon.badge(unreadCount, { bgColor: "#dc2626" });socket.on("open", () => favicon.status("success"));
socket.on("reconnecting", () => favicon.spin({ color: "#f59e0b" }));
socket.on("error", () => favicon.status("error"));Modern evergreen browsers with Canvas API support.
pnpm install
pnpm run build
pnpm run test
pnpm run size
pnpm run size:check
pnpm run dev- Demo app:
demo/ - Source:
src/index.ts
The project includes a gzip size check script and enforces a hard budget of 5KB max for dist/index.js.gz.
- Latest measured size: 2503 bytes gzipped (February 25, 2026)
- CI fails if size exceeds the 5KB budget
pnpm changesetThen commit the generated file under .changeset/.
On main pushes, GitHub Actions will:
- run
pnpm run check - open/update a release PR if unpublished changesets exist
- publish to npm when the release PR is merged
Required GitHub repository secrets:
NPM_TOKEN
MIT