Icon Badge
Original · freebadges with leading icons
byWeb InnoventixReact + Tailwind
badgeiconbadges
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/badge-icon.jsonbadge-icon.tsx
"use client";
import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type BadgeColor =
| "emerald"
| "amber"
| "rose"
| "indigo"
| "violet"
| "sky"
| "slate";
type BadgeSize = "sm" | "md" | "lg";
type BadgeStyle = "soft" | "solid" | "outline";
type IconName =
| "flame"
| "shield"
| "bolt"
| "accessibility"
| "sparkles"
| "book"
| "star"
| "check"
| "filter"
| "close"
| "trend-down";
type Label = {
id: string;
name: string;
icon: IconName;
color: BadgeColor;
count: number;
};
const LABELS: ReadonlyArray<Label> = [
{ id: "critical", name: "Critical", icon: "flame", color: "rose", count: 8 },
{ id: "security", name: "Security", icon: "shield", color: "violet", count: 5 },
{ id: "perf", name: "Performance", icon: "bolt", color: "amber", count: 12 },
{ id: "a11y", name: "Accessibility", icon: "accessibility", color: "sky", count: 6 },
{ id: "enhancement", name: "Enhancement", icon: "sparkles", color: "indigo", count: 17 },
{ id: "docs", name: "Documentation", icon: "book", color: "slate", count: 9 },
{ id: "first", name: "Good first issue", icon: "star", color: "emerald", count: 14 },
];
const SOFT: Record<BadgeColor, string> = {
emerald:
"bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
amber:
"bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20",
rose: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20",
indigo:
"bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-600/20 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20",
violet:
"bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-600/20 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/20",
sky: "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/20",
slate:
"bg-slate-100 text-slate-700 ring-1 ring-inset ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-400/20",
};
const SOLID: Record<BadgeColor, string> = {
emerald:
"bg-emerald-600 text-white ring-1 ring-inset ring-emerald-700/40 dark:bg-emerald-500 dark:text-emerald-950 dark:ring-emerald-400/30",
amber:
"bg-amber-600 text-white ring-1 ring-inset ring-amber-700/40 dark:bg-amber-400 dark:text-amber-950 dark:ring-amber-300/30",
rose: "bg-rose-600 text-white ring-1 ring-inset ring-rose-700/40 dark:bg-rose-500 dark:text-rose-950 dark:ring-rose-400/30",
indigo:
"bg-indigo-600 text-white ring-1 ring-inset ring-indigo-700/40 dark:bg-indigo-500 dark:text-white dark:ring-indigo-400/30",
violet:
"bg-violet-600 text-white ring-1 ring-inset ring-violet-700/40 dark:bg-violet-500 dark:text-white dark:ring-violet-400/30",
sky: "bg-sky-600 text-white ring-1 ring-inset ring-sky-700/40 dark:bg-sky-500 dark:text-sky-950 dark:ring-sky-400/30",
slate:
"bg-slate-700 text-white ring-1 ring-inset ring-slate-800/40 dark:bg-slate-200 dark:text-slate-900 dark:ring-slate-300/30",
};
const OUTLINE: Record<BadgeColor, string> = {
emerald:
"bg-transparent text-emerald-700 ring-1 ring-inset ring-emerald-600/40 dark:text-emerald-300 dark:ring-emerald-400/40",
amber:
"bg-transparent text-amber-700 ring-1 ring-inset ring-amber-600/40 dark:text-amber-300 dark:ring-amber-400/40",
rose: "bg-transparent text-rose-700 ring-1 ring-inset ring-rose-600/40 dark:text-rose-300 dark:ring-rose-400/40",
indigo:
"bg-transparent text-indigo-700 ring-1 ring-inset ring-indigo-600/40 dark:text-indigo-300 dark:ring-indigo-400/40",
violet:
"bg-transparent text-violet-700 ring-1 ring-inset ring-violet-600/40 dark:text-violet-300 dark:ring-violet-400/40",
sky: "bg-transparent text-sky-700 ring-1 ring-inset ring-sky-600/40 dark:text-sky-300 dark:ring-sky-400/40",
slate:
"bg-transparent text-slate-700 ring-1 ring-inset ring-slate-500/40 dark:text-slate-300 dark:ring-slate-400/40",
};
const SIZE_PAD: Record<BadgeSize, string> = {
sm: "gap-1 px-2 py-0.5 text-xs",
md: "gap-1.5 px-3 py-1 text-sm",
lg: "gap-2 px-4 py-1.5 text-[0.95rem]",
};
const ICON_SIZE: Record<BadgeSize, string> = {
sm: "h-3 w-3",
md: "h-3.5 w-3.5",
lg: "h-4 w-4",
};
const SIZE_OPTIONS: ReadonlyArray<{ value: BadgeSize; label: string }> = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
];
const STYLE_OPTIONS: ReadonlyArray<{ value: BadgeStyle; label: string }> = [
{ value: "soft", label: "Soft" },
{ value: "solid", label: "Solid" },
{ value: "outline", label: "Outline" },
];
const FOCUS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
function Glyph({ name, className }: { name: IconName; className?: string }) {
const shared = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
className,
};
switch (name) {
case "flame":
return (
<svg {...shared}>
<path d="M12 2.5c2.5 3 4.5 5.2 4.5 8.6a4.5 4.5 0 1 1-9 0c0-1.7.7-2.9 1.8-4 .2 1 .8 1.7 1.7 2 .1-2.4-.5-4.6 1-6.6Z" />
</svg>
);
case "shield":
return (
<svg {...shared}>
<path d="M12 3 5 6v5c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
<path d="m9.2 12 2 2 3.6-3.6" />
</svg>
);
case "bolt":
return (
<svg {...shared}>
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" />
</svg>
);
case "accessibility":
return (
<svg {...shared}>
<circle cx="12" cy="4.4" r="1.5" />
<path d="M5 8.4c2 .8 4.4 1.2 7 1.2s5-.4 7-1.2M12 9.7V15m0 0-3 6m3-6 3 6" />
</svg>
);
case "sparkles":
return (
<svg {...shared}>
<path d="M12 3.5 13.5 8 18 9.5 13.5 11 12 15.5 10.5 11 6 9.5 10.5 8 12 3.5Z" />
<path d="M18 14.5 18.8 16.7 21 17.5 18.8 18.3 18 20.5 17.2 18.3 15 17.5 17.2 16.7 18 14.5Z" />
</svg>
);
case "book":
return (
<svg {...shared}>
<path d="M12 6c-1.5-1.2-3.5-2-6-2H4v13h2c2.5 0 4.5.8 6 2m0-13c1.5-1.2 3.5-2 6-2h2v13h-2c-2.5 0-4.5.8-6 2m0-13v13" />
</svg>
);
case "star":
return (
<svg {...shared}>
<path d="m12 3 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.2l5.9-.9L12 3Z" />
</svg>
);
case "check":
return (
<svg {...shared}>
<path d="m5 12.5 4.5 4.5L19 6.5" />
</svg>
);
case "filter":
return (
<svg {...shared}>
<path d="M4 5h16l-6.5 8v5l-3 2v-7L4 5Z" />
</svg>
);
case "close":
return (
<svg {...shared}>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
case "trend-down":
return (
<svg {...shared}>
<path d="M3 7l7 7 3.5-3.5L21 17M21 17h-5m5 0v-5" />
</svg>
);
default:
return null;
}
}
function Segmented<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: ReadonlyArray<{ value: T; label: string }>;
value: T;
onChange: (next: T) => void;
}) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
function handleKey(event: KeyboardEvent<HTMLButtonElement>, index: number) {
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
next = (index + 1) % options.length;
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
next = (index - 1 + options.length) % options.length;
} else if (event.key === "Home") {
next = 0;
} else if (event.key === "End") {
next = options.length - 1;
} else {
return;
}
event.preventDefault();
onChange(options[next].value);
refs.current[next]?.focus();
}
return (
<div
role="radiogroup"
aria-label={label}
className="inline-flex items-center gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/80"
>
{options.map((opt, index) => {
const active = opt.value === value;
return (
<button
key={opt.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(opt.value)}
onKeyDown={(event) => handleKey(event, index)}
className={[
"rounded-lg px-3 py-1.5 text-sm font-medium transition",
FOCUS,
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
].join(" ")}
>
{opt.label}
</button>
);
})}
</div>
);
}
export default function BadgeIcon() {
const reduce = useReducedMotion();
const summaryId = useId();
const [size, setSize] = useState<BadgeSize>("md");
const [variant, setVariant] = useState<BadgeStyle>("soft");
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const styleMap = variant === "solid" ? SOLID : variant === "outline" ? OUTLINE : SOFT;
const { totalIssues, selectedCount, matchingIssues } = useMemo(() => {
const total = LABELS.reduce((sum, l) => sum + l.count, 0);
const chosen = LABELS.filter((l) => selected.has(l.id));
const matching = chosen.reduce((sum, l) => sum + l.count, 0);
return {
totalIssues: total,
selectedCount: chosen.length,
matchingIssues: chosen.length === 0 ? total : matching,
};
}, [selected]);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
const anySelected = selectedCount > 0;
const iconCls = ICON_SIZE[size];
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:py-20 dark:bg-slate-950 dark:text-white">
<style>{`
@keyframes bi_ping {
0% { transform: scale(1); opacity: 0.55; }
75%, 100% { transform: scale(2.4); opacity: 0; }
}
.bi-ping { animation: bi_ping 1.7s cubic-bezier(0, 0, 0.2, 1) infinite; transform-origin: center; }
@media (prefers-reduced-motion: reduce) {
.bi-ping { animation: none; }
}
`}</style>
<div className="mx-auto max-w-4xl">
<header className="mb-8">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
<Glyph name="filter" className="h-3.5 w-3.5" />
Triage board
</span>
<h2 className="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Filter issues by label
</h2>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-400">
Toggle any label to focus the queue. Every badge carries a leading
glyph so its category reads at a glance — tuned for both light and
dark, three sizes, and three styles.
</p>
</header>
<div className="rounded-2xl border border-slate-200/80 bg-white/70 p-6 shadow-sm backdrop-blur-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900/60">
<div className="flex flex-wrap items-end gap-x-8 gap-y-5">
<div>
<p className="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">
Size
</p>
<Segmented<BadgeSize>
label="Badge size"
options={SIZE_OPTIONS}
value={size}
onChange={setSize}
/>
</div>
<div>
<p className="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">
Style
</p>
<Segmented<BadgeStyle>
label="Badge style"
options={STYLE_OPTIONS}
value={variant}
onChange={setVariant}
/>
</div>
</div>
<div className="my-6 h-px w-full bg-slate-200 dark:bg-slate-800" />
<div
role="group"
aria-label="Issue labels"
className="flex flex-wrap items-center gap-2.5"
>
{LABELS.map((label, index) => {
const isSelected = selected.has(label.id);
const appearance = isSelected ? SOLID[label.color] : styleMap[label.color];
const muted = anySelected && !isSelected;
return (
<motion.button
key={label.id}
type="button"
aria-pressed={isSelected}
aria-label={`${label.name}, ${label.count} open issues${
isSelected ? ", selected" : ""
}`}
onClick={() => toggle(label.id)}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.25,
delay: reduce ? 0 : index * 0.035,
ease: "easeOut",
}}
className={[
"inline-flex items-center rounded-full font-medium transition duration-200 select-none",
"hover:brightness-105 active:scale-95 dark:hover:brightness-125",
SIZE_PAD[size],
appearance,
isSelected ? "font-semibold" : "",
muted ? "opacity-55 hover:opacity-100" : "",
FOCUS,
].join(" ")}
>
<Glyph name={label.icon} className={iconCls} />
<span>{label.name}</span>
<span className="tabular-nums opacity-70">{label.count}</span>
{isSelected ? (
<Glyph name="check" className={`${iconCls} -mr-0.5`} />
) : null}
</motion.button>
);
})}
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<p
id={summaryId}
role="status"
aria-live="polite"
className="text-sm text-slate-600 dark:text-slate-400"
>
{selectedCount === 0 ? (
<>
Showing all{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{LABELS.length}
</span>{" "}
labels ·{" "}
<span className="font-semibold tabular-nums text-slate-900 dark:text-white">
{totalIssues}
</span>{" "}
open issues
</>
) : (
<>
<span className="font-semibold text-slate-900 dark:text-white">
{selectedCount}
</span>{" "}
of {LABELS.length} labels ·{" "}
<span className="font-semibold tabular-nums text-slate-900 dark:text-white">
{matchingIssues}
</span>{" "}
matching issues
</>
)}
</p>
<AnimatePresence>
{anySelected ? (
<motion.button
key="clear"
type="button"
onClick={() => setSelected(new Set())}
initial={reduce ? false : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
transition={{ duration: 0.18 }}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium text-slate-600 ring-1 ring-inset ring-slate-300 transition hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-800 dark:hover:text-white ${FOCUS}`}
>
<Glyph name="close" className="h-3.5 w-3.5" />
Clear all
</motion.button>
) : null}
</AnimatePresence>
</div>
</div>
<div className="mt-6 flex flex-wrap items-center gap-2.5">
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">
Status variants:
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20">
<span className="relative flex h-2 w-2" aria-hidden="true">
<span className="bi-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
Deploy live
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-violet-600 px-2.5 py-1 text-xs font-semibold text-white ring-1 ring-inset ring-violet-700/40 dark:bg-violet-500 dark:ring-violet-400/30">
<Glyph name="sparkles" className="h-3.5 w-3.5" />
Beta
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 ring-1 ring-inset ring-rose-600/20 tabular-nums dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20">
<Glyph name="trend-down" className="h-3.5 w-3.5" />
-12% load time
</span>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Badge
Originalbasic badges across colours

Dot Badge
Originaldot status badges

Count Badge
Originalnotification count badges

Status Badge
Originalstatus pills (active/pending/error)

Pill Badge
Originalsoft pill badges

Outline Badge
Originaloutline badges

Gradient Badge
Originalgradient badges

Removable Badge
Originalremovable chip badges

Input Chip
Originalchip input tokens

Filter Chip
Originalselectable filter chips

Cloud Tag
Originalweighted tag cloud

Notification Badge
Originalicon with notification badge

