Outline Badge
Original · freeoutline badges
byWeb InnoventixReact + Tailwind
badgeoutlinebadges
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-outline.jsonbadge-outline.tsx
"use client";
import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type BadgeColor =
| "indigo"
| "violet"
| "emerald"
| "rose"
| "amber"
| "sky"
| "slate";
interface OutlineStyle {
dot: string;
active: string;
ring: string;
}
interface Label {
id: string;
name: string;
color: BadgeColor;
}
interface Issue {
id: number;
title: string;
labels: string[];
author: string;
comments: number;
when: string;
}
type MatchMode = "any" | "all";
const OUTLINE_STYLES: Record<BadgeColor, OutlineStyle> = {
indigo: {
dot: "bg-indigo-500",
active:
"border-indigo-400 bg-indigo-50 text-indigo-700 dark:border-indigo-400/70 dark:bg-indigo-500/10 dark:text-indigo-200",
ring: "focus-visible:ring-indigo-400/70",
},
violet: {
dot: "bg-violet-500",
active:
"border-violet-400 bg-violet-50 text-violet-700 dark:border-violet-400/70 dark:bg-violet-500/10 dark:text-violet-200",
ring: "focus-visible:ring-violet-400/70",
},
emerald: {
dot: "bg-emerald-500",
active:
"border-emerald-400 bg-emerald-50 text-emerald-700 dark:border-emerald-400/70 dark:bg-emerald-500/10 dark:text-emerald-200",
ring: "focus-visible:ring-emerald-400/70",
},
rose: {
dot: "bg-rose-500",
active:
"border-rose-400 bg-rose-50 text-rose-700 dark:border-rose-400/70 dark:bg-rose-500/10 dark:text-rose-200",
ring: "focus-visible:ring-rose-400/70",
},
amber: {
dot: "bg-amber-500",
active:
"border-amber-400 bg-amber-50 text-amber-700 dark:border-amber-400/70 dark:bg-amber-500/10 dark:text-amber-200",
ring: "focus-visible:ring-amber-400/70",
},
sky: {
dot: "bg-sky-500",
active:
"border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-400/70 dark:bg-sky-500/10 dark:text-sky-200",
ring: "focus-visible:ring-sky-400/70",
},
slate: {
dot: "bg-slate-500",
active:
"border-slate-400 bg-slate-100 text-slate-700 dark:border-slate-500 dark:bg-slate-700/40 dark:text-slate-200",
ring: "focus-visible:ring-slate-400/70",
},
};
const INACTIVE_OUTLINE =
"border-slate-300 text-slate-600 hover:border-slate-400 hover:text-slate-900 " +
"dark:border-slate-700 dark:text-slate-300 dark:hover:border-slate-500 dark:hover:text-white";
const LABELS: Label[] = [
{ id: "bug", name: "bug", color: "rose" },
{ id: "feature", name: "feature", color: "indigo" },
{ id: "design", name: "design", color: "violet" },
{ id: "performance", name: "performance", color: "amber" },
{ id: "security", name: "security", color: "emerald" },
{ id: "docs", name: "docs", color: "sky" },
{ id: "breaking", name: "breaking", color: "slate" },
];
const LABEL_BY_ID: Record<string, Label> = LABELS.reduce(
(acc, label) => {
acc[label.id] = label;
return acc;
},
{} as Record<string, Label>,
);
const ISSUES: Issue[] = [
{
id: 482,
title: "Date picker drops focus when navigating months with arrow keys",
labels: ["bug", "design"],
author: "arya.n",
comments: 7,
when: "2h ago",
},
{
id: 470,
title: "Add virtualized rendering to the data grid",
labels: ["feature", "performance"],
author: "leo.k",
comments: 21,
when: "5h ago",
},
{
id: 465,
title: "Document the new theming tokens API",
labels: ["docs"],
author: "priya.s",
comments: 3,
when: "yesterday",
},
{
id: 461,
title: "Sanitize SVG uploads in the avatar service",
labels: ["security", "bug"],
author: "marcus.d",
comments: 12,
when: "yesterday",
},
{
id: 458,
title: "Reduce bundle size by tree-shaking the icon set",
labels: ["performance"],
author: "leo.k",
comments: 9,
when: "2 days ago",
},
{
id: 451,
title: "Redesign empty states across the dashboard",
labels: ["design", "feature"],
author: "arya.n",
comments: 15,
when: "3 days ago",
},
{
id: 449,
title: "Migrate auth cookies to SameSite=Lax",
labels: ["security", "breaking"],
author: "marcus.d",
comments: 28,
when: "4 days ago",
},
{
id: 444,
title: "Fix contrast ratio on disabled buttons",
labels: ["bug", "design"],
author: "priya.s",
comments: 4,
when: "5 days ago",
},
];
function DotIcon({ className }: { className: string }) {
return <span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${className}`} />;
}
function CloseIcon() {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
className="h-3 w-3"
>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
);
}
function CheckIcon() {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-3.5 w-3.5"
>
<path d="M4 10.5l4 4 8-9" />
</svg>
);
}
function BoltIcon() {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="currentColor"
className="h-3.5 w-3.5"
>
<path d="M11 1L3 11h5l-2 8 9-11h-5l2-7z" />
</svg>
);
}
/** Small read-only outline badge used inside issue rows. */
function LabelBadge({ label }: { label: Label }) {
const style = OUTLINE_STYLES[label.color];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${style.active}`}
>
<DotIcon className={style.dot} />
{label.name}
</span>
);
}
export default function BadgeOutline() {
const reduceMotion = useReducedMotion();
const [selected, setSelected] = useState<string[]>([]);
const [mode, setMode] = useState<MatchMode>("any");
const toggle = (id: string) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const results = useMemo(() => {
if (selected.length === 0) return ISSUES;
if (mode === "all") {
return ISSUES.filter((issue) =>
selected.every((id) => issue.labels.includes(id)),
);
}
return ISSUES.filter((issue) =>
issue.labels.some((id) => selected.includes(id)),
);
}, [selected, mode]);
const enter = reduceMotion
? { opacity: 1 }
: { opacity: 1, y: 0, transition: { duration: 0.24, ease: "easeOut" as const } };
const initial = reduceMotion ? { opacity: 0 } : { opacity: 0, y: 10 };
const exit = reduceMotion
? { opacity: 0 }
: { opacity: 0, y: -8, scale: 0.98, transition: { duration: 0.16 } };
return (
<section className="relative w-full overflow-hidden bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-24">
<style>{`
@keyframes bdgotl-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.72); }
}
.bdgotl-livedot { animation: bdgotl-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.bdgotl-livedot { animation: none; }
}
`}</style>
{/* subtle backdrop texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(99,102,241,0.08),transparent)] dark:bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(99,102,241,0.14),transparent)]"
/>
<div className="relative mx-auto max-w-4xl">
<header className="mb-10">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:border-indigo-500/50 dark:text-indigo-300">
<DotIcon className="bg-indigo-500" />
Outline badges
</span>
<h2 className="mt-5 text-3xl font-semibold tracking-tight sm:text-4xl">
Filter the backlog by label
</h2>
<p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
Outline badges double as toggleable filter chips. Pick one or more
labels, choose how they combine, and the issue list updates live.
</p>
</header>
<div className="rounded-2xl border border-slate-200 bg-white/60 p-5 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/40 sm:p-6">
{/* Toolbar */}
<div className="flex flex-col gap-4 border-b border-slate-200 pb-5 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
Filter by label
</h3>
</div>
<div className="flex items-center gap-3">
<fieldset className="flex items-center gap-2">
<legend className="sr-only">Match mode</legend>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
Match
</span>
<div className="inline-flex rounded-full border border-slate-300 p-0.5 dark:border-slate-700">
{(["any", "all"] as const).map((m) => (
<label key={m} className="relative cursor-pointer">
<input
type="radio"
name="bdgotl-match"
value={m}
checked={mode === m}
onChange={() => setMode(m)}
className="peer sr-only"
/>
<span className="inline-block rounded-full px-3 py-1 text-xs font-medium text-slate-600 transition-colors peer-checked:bg-slate-900 peer-checked:text-white peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400/70 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:text-slate-300 dark:peer-checked:bg-white dark:peer-checked:text-slate-900 dark:peer-focus-visible:ring-offset-slate-900">
{m === "any" ? "Any" : "All"}
</span>
</label>
))}
</div>
</fieldset>
<button
type="button"
onClick={() => setSelected([])}
disabled={selected.length === 0}
className="rounded-full border border-slate-300 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-300 dark:hover:border-slate-500 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
Clear
</button>
</div>
</div>
{/* Toggle badge chips */}
<div className="flex flex-wrap gap-2 pt-5">
{LABELS.map((label) => {
const isActive = selected.includes(label.id);
const style = OUTLINE_STYLES[label.color];
return (
<button
key={label.id}
type="button"
aria-pressed={isActive}
onClick={() => toggle(label.id)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${style.ring} ${
isActive ? style.active : INACTIVE_OUTLINE
}`}
>
<DotIcon className={isActive ? style.dot : "bg-slate-400 dark:bg-slate-500"} />
{label.name}
</button>
);
})}
</div>
{/* Selected summary — removable outline badges */}
<AnimatePresence initial={false}>
{selected.length > 0 && (
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={
reduceMotion
? { opacity: 1 }
: { opacity: 1, height: "auto", transition: { duration: 0.22 } }
}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.18 } }}
className="overflow-hidden"
>
<div className="mt-5 flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-slate-50/60 p-3 dark:border-slate-700 dark:bg-slate-800/30">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
Active:
</span>
{selected.map((id) => {
const label = LABEL_BY_ID[id];
const style = OUTLINE_STYLES[label.color];
return (
<span
key={id}
className={`inline-flex items-center gap-1.5 rounded-full border py-0.5 pl-2.5 pr-1 text-xs font-medium ${style.active}`}
>
<DotIcon className={style.dot} />
{label.name}
<button
type="button"
onClick={() => toggle(id)}
aria-label={`Remove ${label.name} filter`}
className={`ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full opacity-70 transition hover:bg-black/5 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-transparent dark:hover:bg-white/10 ${style.ring}`}
>
<CloseIcon />
</button>
</span>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Results header */}
<div className="mt-6 flex items-center gap-2 text-sm">
<span className="bdgotl-livedot inline-block h-2 w-2 rounded-full bg-emerald-500" aria-hidden />
<p aria-live="polite" className="font-medium text-slate-600 dark:text-slate-300">
Showing {results.length} of {ISSUES.length} issues
</p>
</div>
{/* Results list */}
<ul className="mt-3 space-y-2">
<AnimatePresence initial={false}>
{results.map((issue) => (
<motion.li
key={issue.id}
layout={!reduceMotion}
initial={initial}
animate={enter}
exit={exit}
className="rounded-xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900/60"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{issue.title}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
#{issue.id} · opened by {issue.author} · {issue.when}
</p>
</div>
<span className="shrink-0 rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium tabular-nums text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{issue.comments} comments
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{issue.labels.map((id) => (
<LabelBadge key={id} label={LABEL_BY_ID[id]} />
))}
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
{results.length === 0 && (
<div className="mt-3 rounded-xl border border-dashed border-slate-300 px-4 py-10 text-center dark:border-slate-700">
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">
No issues match all of these labels
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Try switching the match mode to <span className="font-semibold">Any</span>.
</p>
</div>
)}
</div>
{/* Variant showcase */}
<div className="mt-10">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
Outline variants
</h3>
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-4">
{/* sizes */}
<span className="inline-flex items-center rounded-full border border-indigo-400 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:border-indigo-400/70 dark:text-indigo-200">
New
</span>
<span className="inline-flex items-center rounded-full border border-indigo-400 px-3 py-1 text-sm font-medium text-indigo-700 dark:border-indigo-400/70 dark:text-indigo-200">
Beta
</span>
<span className="inline-flex items-center rounded-full border border-indigo-400 px-4 py-1.5 text-base font-medium text-indigo-700 dark:border-indigo-400/70 dark:text-indigo-200">
Stable
</span>
<span aria-hidden className="h-6 w-px bg-slate-200 dark:bg-slate-700" />
{/* with dot */}
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400 px-3 py-1 text-sm font-medium text-emerald-700 dark:border-emerald-400/70 dark:text-emerald-200">
<DotIcon className="bg-emerald-500" />
Operational
</span>
{/* with icon */}
<span className="inline-flex items-center gap-1.5 rounded-full border border-sky-400 px-3 py-1 text-sm font-medium text-sky-700 dark:border-sky-400/70 dark:text-sky-200">
<CheckIcon />
Verified
</span>
{/* with icon amber */}
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-400 px-3 py-1 text-sm font-medium text-amber-700 dark:border-amber-400/70 dark:text-amber-200">
<BoltIcon />
Rate limited
</span>
{/* with count */}
<span className="inline-flex items-center gap-2 rounded-full border border-violet-400 py-1 pl-3 pr-1 text-sm font-medium text-violet-700 dark:border-violet-400/70 dark:text-violet-200">
Reviews
<span className="inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-violet-500 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-white">
128
</span>
</span>
</div>
</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

Gradient Badge
Originalgradient badges
Icon Badge
Originalbadges with leading icons

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

