Basic Badge
Original · freebasic badges across colours
byWeb InnoventixReact + Tailwind
badgebasicbadges
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-basic.jsonbadge-basic.tsx
"use client";
import {
type KeyboardEvent as ReactKeyboardEvent,
useId,
useRef,
useState,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Variant = "solid" | "soft" | "outline";
type Tone =
| "emerald"
| "sky"
| "indigo"
| "violet"
| "amber"
| "rose"
| "slate"
| "zinc";
type Filter = { id: string; tone: Tone; label: string };
const TONES: readonly Tone[] = [
"emerald",
"sky",
"indigo",
"violet",
"amber",
"rose",
"slate",
"zinc",
];
const TONE_LABEL: Record<Tone, string> = {
emerald: "Shipped",
sky: "In review",
indigo: "Planned",
violet: "Beta",
amber: "Needs QA",
rose: "Blocked",
slate: "Draft",
zinc: "Archived",
};
const SOLID: Record<Tone, string> = {
emerald: "bg-emerald-600 text-white dark:bg-emerald-500",
sky: "bg-sky-600 text-white dark:bg-sky-500",
indigo: "bg-indigo-600 text-white dark:bg-indigo-500",
violet: "bg-violet-600 text-white dark:bg-violet-500",
amber: "bg-amber-400 text-amber-950 dark:bg-amber-400 dark:text-amber-950",
rose: "bg-rose-600 text-white dark:bg-rose-500",
slate: "bg-slate-700 text-white dark:bg-slate-200 dark:text-slate-900",
zinc: "bg-zinc-700 text-white dark:bg-zinc-200 dark:text-zinc-900",
};
const SOFT: Record<Tone, string> = {
emerald:
"bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
sky: "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
indigo:
"bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
violet:
"bg-violet-50 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
amber: "bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
rose: "bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
slate: "bg-slate-100 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300",
zinc: "bg-zinc-100 text-zinc-700 dark:bg-zinc-400/15 dark:text-zinc-300",
};
const OUTLINE: Record<Tone, string> = {
emerald:
"border border-emerald-300 text-emerald-700 dark:border-emerald-500/40 dark:text-emerald-300",
sky: "border border-sky-300 text-sky-700 dark:border-sky-500/40 dark:text-sky-300",
indigo:
"border border-indigo-300 text-indigo-700 dark:border-indigo-500/40 dark:text-indigo-300",
violet:
"border border-violet-300 text-violet-700 dark:border-violet-500/40 dark:text-violet-300",
amber:
"border border-amber-400 text-amber-700 dark:border-amber-500/40 dark:text-amber-300",
rose: "border border-rose-300 text-rose-700 dark:border-rose-500/40 dark:text-rose-300",
slate:
"border border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300",
zinc: "border border-zinc-300 text-zinc-700 dark:border-zinc-600 dark:text-zinc-300",
};
const DOT_TONE: Record<Tone, string> = {
emerald: "bg-emerald-500",
sky: "bg-sky-500",
indigo: "bg-indigo-500",
violet: "bg-violet-500",
amber: "bg-amber-500",
rose: "bg-rose-500",
slate: "bg-slate-500",
zinc: "bg-zinc-500",
};
const VARIANTS: readonly { id: Variant; label: string }[] = [
{ id: "solid", label: "Solid" },
{ id: "soft", label: "Soft" },
{ id: "outline", label: "Outline" },
];
const INITIAL_FILTERS: readonly Filter[] = [
{ id: "priority", tone: "rose", label: "Priority: High" },
{ id: "team", tone: "indigo", label: "Team: Platform" },
{ id: "label", tone: "violet", label: "Label: Accessibility" },
{ id: "milestone", tone: "sky", label: "Milestone: v2.4" },
{ id: "status", tone: "emerald", label: "Status: Ready" },
{ id: "type", tone: "amber", label: "Type: Bug" },
];
const KEYFRAMES = `
@keyframes badgebasic_ping {
0% { transform: scale(1); opacity: 0.55; }
75%, 100% { transform: scale(2.4); opacity: 0; }
}
@keyframes badgebasic_pop {
from { opacity: 0; transform: translateY(5px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.badgebasic-ping { animation: badgebasic_ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite; }
.badgebasic-pop { animation: badgebasic_pop 0.35s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.badgebasic-ping, .badgebasic-pop { animation: none !important; }
}
`;
function badgeClasses(variant: Variant, tone: Tone): string {
if (variant === "solid") return SOLID[tone];
if (variant === "soft") return SOFT[tone];
return OUTLINE[tone];
}
function dotClasses(variant: Variant, tone: Tone): string {
return variant === "solid" ? "bg-current opacity-80" : DOT_TONE[tone];
}
export default function BadgeBasic() {
const reduce = useReducedMotion();
const headingId = useId();
const paletteId = useId();
const filtersId = useId();
const [variant, setVariant] = useState<Variant>("soft");
const [showDot, setShowDot] = useState(true);
const [filters, setFilters] = useState<Filter[]>([...INITIAL_FILTERS]);
const variantRefs = useRef<Array<HTMLButtonElement | null>>([]);
const handleVariantKey = (
event: ReactKeyboardEvent<HTMLButtonElement>,
index: number,
) => {
const count = VARIANTS.length;
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
next = (index + 1) % count;
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
next = (index - 1 + count) % count;
} else if (event.key === "Home") {
next = 0;
} else if (event.key === "End") {
next = count - 1;
} else {
return;
}
event.preventDefault();
setVariant(VARIANTS[next].id);
variantRefs.current[next]?.focus();
};
const removeFilter = (id: string) =>
setFilters((prev) => prev.filter((item) => item.id !== id));
const resetFilters = () => setFilters([...INITIAL_FILTERS]);
const isFull = filters.length === INITIAL_FILTERS.length;
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500";
return (
<section
aria-labelledby={headingId}
className="relative w-full bg-slate-50 px-6 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
>
<style>{KEYFRAMES}</style>
<div className="mx-auto max-w-4xl">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Design system
</p>
<h2
id={headingId}
className="mt-3 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white"
>
Status badges
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
A basic badge set spanning eight colours in solid, soft, and
outline styles. Switch the controls to preview every variant live,
then dismiss filters to try the interactive tag list.
</p>
</header>
{/* Controls */}
<div className="mt-10 flex flex-col gap-6 rounded-2xl border border-slate-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-wrap items-center gap-6">
<div
role="radiogroup"
aria-label="Badge style"
className="relative inline-flex rounded-xl bg-slate-100 p-1 dark:bg-slate-800"
>
{VARIANTS.map((item, index) => {
const active = variant === item.id;
return (
<button
key={item.id}
ref={(el) => {
variantRefs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => setVariant(item.id)}
onKeyDown={(event) => handleVariantKey(event, index)}
className={`relative z-10 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
active
? "text-slate-900 dark:text-white"
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{active && (
<motion.span
layoutId="badgebasic-seg"
aria-hidden="true"
className="absolute inset-0 -z-10 rounded-lg bg-white shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-950 dark:ring-white/10"
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 38 }
}
/>
)}
{item.label}
</button>
);
})}
</div>
<button
type="button"
role="switch"
aria-checked={showDot}
onClick={() => setShowDot((value) => !value)}
className={`group inline-flex items-center gap-2.5 rounded-full ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
>
<span
aria-hidden="true"
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
showDot
? "bg-indigo-600 dark:bg-indigo-500"
: "bg-slate-300 dark:bg-slate-600"
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
showDot ? "left-[1.125rem]" : "left-0.5"
}`}
/>
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
Status dot
</span>
</button>
</div>
{/* Live indicator example */}
<span className="inline-flex items-center gap-2 self-start rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 sm:self-auto dark:bg-emerald-500/15 dark:text-emerald-300">
<span className="relative flex h-2 w-2">
<span
aria-hidden="true"
className="badgebasic-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>
342 online
</span>
</div>
{/* Palette */}
<section aria-labelledby={paletteId} className="mt-10">
<h3
id={paletteId}
className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
{VARIANTS.find((item) => item.id === variant)?.label} palette
</h3>
<ul key={variant} className="mt-4 flex flex-wrap gap-2.5">
{TONES.map((tone, index) => (
<li
key={tone}
className="badgebasic-pop"
style={{ animationDelay: `${index * 40}ms` }}
>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${badgeClasses(
variant,
tone,
)}`}
>
{showDot && (
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${dotClasses(
variant,
tone,
)}`}
/>
)}
{TONE_LABEL[tone]}
</span>
</li>
))}
</ul>
</section>
{/* Interactive removable tags */}
<section
aria-labelledby={filtersId}
className="mt-10 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2.5">
<h3
id={filtersId}
className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Applied filters
</h3>
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-slate-200 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{filters.length}
</span>
</div>
<button
type="button"
onClick={resetFilters}
disabled={isFull}
className={`rounded-lg px-2.5 py-1 text-sm font-medium text-indigo-600 transition-colors enabled:hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-40 dark:text-indigo-400 dark:enabled:hover:bg-indigo-500/10 ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
>
Reset
</button>
</div>
<span className="sr-only" aria-live="polite">
{filters.length} filters applied
</span>
{filters.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
No filters applied. Press Reset to restore the default set.
</p>
) : (
<ul className="mt-4 flex flex-wrap gap-2">
<AnimatePresence initial={false}>
{filters.map((item) => (
<motion.li
key={item.id}
layout={reduce ? false : true}
initial={reduce ? false : { opacity: 0, scale: 0.85 }}
animate={reduce ? {} : { opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}
transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
className={`inline-flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-semibold ${SOFT[item.tone]}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${DOT_TONE[item.tone]}`}
/>
{item.label}
<button
type="button"
onClick={() => removeFilter(item.id)}
aria-label={`Remove ${item.label}`}
className={`ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full opacity-70 transition hover:bg-slate-900/10 hover:opacity-100 dark:hover:bg-white/15 ${focusRing}`}
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-3 w-3"
>
<path
d="M4.5 4.5l7 7M11.5 4.5l-7 7"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
</section>
</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 →
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
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

