Removable Badge
Original · freeremovable chip badges
byWeb InnoventixReact + Tailwind
badgeremovablebadges
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-removable.jsonbadge-removable.tsx
"use client";
import { useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { Transition } from "motion/react";
type Tone = "indigo" | "violet" | "emerald" | "amber" | "sky" | "rose";
type Person = {
id: string;
name: string;
email: string;
tone: Tone;
};
const TONES: Tone[] = ["indigo", "violet", "emerald", "amber", "sky", "rose"];
const CHIP: Record<Tone, string> = {
indigo:
"bg-indigo-50 text-indigo-800 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-400/25",
violet:
"bg-violet-50 text-violet-800 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-200 dark:ring-violet-400/25",
emerald:
"bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-400/25",
amber:
"bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/25",
sky: "bg-sky-50 text-sky-800 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-200 dark:ring-sky-400/25",
rose: "bg-rose-50 text-rose-800 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-200 dark:ring-rose-400/25",
};
const AVATAR: Record<Tone, string> = {
indigo: "bg-indigo-500 text-white dark:bg-indigo-400 dark:text-indigo-950",
violet: "bg-violet-500 text-white dark:bg-violet-400 dark:text-violet-950",
emerald: "bg-emerald-500 text-white dark:bg-emerald-400 dark:text-emerald-950",
amber: "bg-amber-500 text-white dark:bg-amber-400 dark:text-amber-950",
sky: "bg-sky-500 text-white dark:bg-sky-400 dark:text-sky-950",
rose: "bg-rose-500 text-white dark:bg-rose-400 dark:text-rose-950",
};
const REMOVE: Record<Tone, string> = {
indigo:
"hover:bg-indigo-200/70 focus-visible:ring-indigo-500 dark:hover:bg-indigo-400/25 dark:focus-visible:ring-indigo-400",
violet:
"hover:bg-violet-200/70 focus-visible:ring-violet-500 dark:hover:bg-violet-400/25 dark:focus-visible:ring-violet-400",
emerald:
"hover:bg-emerald-200/70 focus-visible:ring-emerald-500 dark:hover:bg-emerald-400/25 dark:focus-visible:ring-emerald-400",
amber:
"hover:bg-amber-200/70 focus-visible:ring-amber-500 dark:hover:bg-amber-400/25 dark:focus-visible:ring-amber-400",
sky: "hover:bg-sky-200/70 focus-visible:ring-sky-500 dark:hover:bg-sky-400/25 dark:focus-visible:ring-sky-400",
rose: "hover:bg-rose-200/70 focus-visible:ring-rose-500 dark:hover:bg-rose-400/25 dark:focus-visible:ring-rose-400",
};
const INITIAL: Person[] = [
{ id: "p-maya", name: "Maya Okonkwo", email: "maya@northwind.studio", tone: "indigo" },
{ id: "p-diego", name: "Diego Alvarez", email: "diego@northwind.studio", tone: "emerald" },
{ id: "p-priya", name: "Priya Nair", email: "priya@northwind.studio", tone: "violet" },
];
const SUGGESTIONS: Person[] = [
{ id: "s-sam", name: "Sam Whitfield", email: "sam@northwind.studio", tone: "amber" },
{ id: "s-lena", name: "Lena Fischer", email: "lena@northwind.studio", tone: "sky" },
{ id: "s-tomas", name: "Tomás Reyes", email: "tomas@northwind.studio", tone: "rose" },
{ id: "s-aisha", name: "Aisha Bello", email: "aisha@northwind.studio", tone: "indigo" },
];
function initialsOf(name: string, email: string): string {
const source = name.trim() || email.split("@")[0].replace(/[._-]+/g, " ").trim();
const words = source.split(/\s+/).filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
function nameFromEmail(value: string): string {
const local = value.split("@")[0].replace(/[._-]+/g, " ").trim();
return local
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
function PlusIcon() {
return (
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
<path d="M10 4.5v11M4.5 10h11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 20 20" className="h-3 w-3" fill="none" aria-hidden="true">
<path d="M5.5 5.5l9 9M14.5 5.5l-9 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function UndoIcon() {
return (
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
<path
d="M7 4L3.5 7.5 7 11M3.5 7.5H12a4.5 4.5 0 0 1 0 9H8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function BadgeRemovable() {
const prefersReduced = useReducedMotion();
const counter = useRef(0);
const undoTimer = useRef<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [people, setPeople] = useState<Person[]>(INITIAL);
const [draft, setDraft] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [announce, setAnnounce] = useState("");
const [dupe, setDupe] = useState(false);
const [lastRemoved, setLastRemoved] = useState<{ person: Person; index: number } | null>(null);
const spring: Transition = prefersReduced
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 34, mass: 0.7 };
const remaining = SUGGESTIONS.filter(
(s) => !people.some((p) => p.email.toLowerCase() === s.email.toLowerCase()),
);
function flashDuplicate() {
setDupe(true);
window.setTimeout(() => setDupe(false), 480);
}
function addPerson(entry: { name?: string; email: string; tone?: Tone }) {
const email = entry.email.trim();
if (!email) return false;
if (people.some((p) => p.email.toLowerCase() === email.toLowerCase())) {
flashDuplicate();
setAnnounce(`${email} is already on the list`);
return false;
}
const tone = entry.tone ?? TONES[counter.current % TONES.length];
counter.current += 1;
const name = entry.name?.trim() || nameFromEmail(email);
const person: Person = { id: `add-${counter.current}-${email}`, name, email, tone };
setPeople((prev) => [...prev, person]);
setAnnounce(`Added ${name}`);
return true;
}
function submitDraft() {
const value = draft.trim();
if (!value) return;
const email = value.includes("@") ? value : `${value.replace(/\s+/g, ".").toLowerCase()}@northwind.studio`;
if (addPerson({ email })) setDraft("");
}
function removeAt(index: number) {
const person = people[index];
if (!person) return;
setPeople((prev) => prev.filter((_, i) => i !== index));
setLastRemoved({ person, index });
setAnnounce(`Removed ${person.name}`);
if (undoTimer.current) window.clearTimeout(undoTimer.current);
undoTimer.current = window.setTimeout(() => setLastRemoved(null), 6000);
const nextLen = people.length - 1;
const target = Math.min(index, nextLen - 1);
window.requestAnimationFrame(() => {
if (target >= 0) {
setActiveIndex(target);
btnRefs.current[target]?.focus();
} else {
inputRef.current?.focus();
}
});
}
function undoRemove() {
if (!lastRemoved) return;
const { person, index } = lastRemoved;
setPeople((prev) => {
const copy = [...prev];
copy.splice(Math.min(index, copy.length), 0, person);
return copy;
});
setAnnounce(`Restored ${person.name}`);
setLastRemoved(null);
if (undoTimer.current) window.clearTimeout(undoTimer.current);
}
function clearAll() {
if (people.length === 0) return;
setPeople([]);
setLastRemoved(null);
setAnnounce("Removed everyone from the list");
if (undoTimer.current) window.clearTimeout(undoTimer.current);
window.requestAnimationFrame(() => inputRef.current?.focus());
}
function onChipsKeyDown(event: KeyboardEvent<HTMLUListElement>) {
if (people.length === 0) return;
const last = people.length - 1;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
const next = Math.min(activeIndex + 1, last);
setActiveIndex(next);
btnRefs.current[next]?.focus();
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
const prev = Math.max(activeIndex - 1, 0);
setActiveIndex(prev);
btnRefs.current[prev]?.focus();
} else if (event.key === "Home") {
event.preventDefault();
setActiveIndex(0);
btnRefs.current[0]?.focus();
} else if (event.key === "End") {
event.preventDefault();
setActiveIndex(last);
btnRefs.current[last]?.focus();
}
}
function onInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
submitDraft();
} else if (event.key === "Backspace" && draft === "" && people.length > 0) {
event.preventDefault();
removeAt(people.length - 1);
}
}
const safeActive = Math.min(activeIndex, Math.max(people.length - 1, 0));
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes bdgrm-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
@keyframes bdgrm-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.bdgrm-shake { animation: bdgrm-shake 0.42s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
.bdgrm-pulse { animation: bdgrm-pulse 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.bdgrm-shake, .bdgrm-pulse { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-2xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:shadow-none">
<div className="p-6 sm:p-9">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-white">
Share “Q3 Launch Board”
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Everyone here can edit. Add a name or email, then press Enter.
</p>
</div>
<span className="inline-flex shrink-0 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-emerald-200 ring-inset dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25">
<span className="bdgrm-pulse h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
Live
</span>
</div>
{/* Chip input field */}
<div
role="group"
aria-label="People with access"
onClick={() => inputRef.current?.focus()}
className={[
"mt-6 flex min-h-[3.5rem] flex-wrap items-center gap-2 rounded-2xl bg-slate-50 p-2.5 ring-1 ring-inset transition-colors",
"focus-within:ring-2 focus-within:ring-slate-900 dark:focus-within:ring-white",
dupe ? "bdgrm-shake ring-rose-300 dark:ring-rose-500/50" : "ring-slate-200 dark:bg-slate-800/40 dark:ring-slate-700",
].join(" ")}
>
<ul role="list" onKeyDown={onChipsKeyDown} className="contents">
<AnimatePresence initial={false} mode="popLayout">
{people.map((person, index) => (
<motion.li
key={person.id}
layout={!prefersReduced}
initial={prefersReduced ? undefined : { opacity: 0, scale: 0.7, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={prefersReduced ? undefined : { opacity: 0, scale: 0.7, y: -4 }}
transition={spring}
className={[
"inline-flex items-center gap-1.5 rounded-full py-1 pl-1 pr-1 text-sm font-medium ring-1 ring-inset",
CHIP[person.tone],
].join(" ")}
>
<span
className={[
"grid h-6 w-6 place-items-center rounded-full text-[11px] font-semibold",
AVATAR[person.tone],
].join(" ")}
aria-hidden="true"
>
{initialsOf(person.name, person.email)}
</span>
<span className="pl-0.5">{person.name}</span>
<button
ref={(el) => {
btnRefs.current[index] = el;
}}
type="button"
tabIndex={index === safeActive ? 0 : -1}
onFocus={() => setActiveIndex(index)}
onClick={() => removeAt(index)}
onKeyDown={(e) => {
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
removeAt(index);
}
}}
aria-label={`Remove ${person.name} (${person.email})`}
className={[
"grid h-6 w-6 place-items-center rounded-full text-current opacity-70 transition hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2",
REMOVE[person.tone],
].join(" ")}
>
<CloseIcon />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<div className="flex min-w-[8rem] flex-1 items-center">
<label htmlFor="bdgrm-input" className="sr-only">
Add a person by name or email
</label>
<input
ref={inputRef}
id="bdgrm-input"
type="text"
inputMode="email"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onInputKeyDown}
aria-describedby="bdgrm-hint"
placeholder={people.length === 0 ? "name@company.com" : "Add another…"}
className="w-full bg-transparent px-1.5 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-white dark:placeholder:text-slate-500"
/>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<p id="bdgrm-hint" className="text-xs text-slate-400 dark:text-slate-500">
Backspace removes the last chip · arrow keys move between them
</p>
<button
type="button"
onClick={clearAll}
disabled={people.length === 0}
className="rounded-md px-1 text-xs font-medium text-slate-500 transition hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-400 dark:hover:text-rose-400 dark:focus-visible:ring-white"
>
Remove all
</button>
</div>
{/* Suggestions */}
<div className="mt-7">
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Suggested from your team
</p>
<div className="mt-3 flex flex-wrap gap-2" role="group" aria-label="Suggested people">
<AnimatePresence initial={false} mode="popLayout">
{remaining.length === 0 ? (
<motion.p
key="all-added"
layout={!prefersReduced}
initial={prefersReduced ? undefined : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReduced ? undefined : { opacity: 0 }}
transition={spring}
className="text-sm text-slate-400 dark:text-slate-500"
>
Everyone suggested is already on the board.
</motion.p>
) : (
remaining.map((s) => (
<motion.button
key={s.id}
layout={!prefersReduced}
initial={prefersReduced ? undefined : { opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={prefersReduced ? undefined : { opacity: 0, scale: 0.85 }}
transition={spring}
type="button"
onClick={() => addPerson({ name: s.name, email: s.email, tone: s.tone })}
className="inline-flex items-center gap-1.5 rounded-full bg-white py-1 pl-1 pr-3 text-sm font-medium text-slate-700 ring-1 ring-slate-200 ring-inset transition hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 dark:bg-slate-800/60 dark:text-slate-200 dark:ring-slate-700 dark:hover:bg-slate-800 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
>
<span
className={[
"grid h-6 w-6 place-items-center rounded-full text-[11px] font-semibold",
AVATAR[s.tone],
].join(" ")}
aria-hidden="true"
>
{initialsOf(s.name, s.email)}
</span>
{s.name}
<span className="text-slate-300 dark:text-slate-500" aria-hidden="true">
<PlusIcon />
</span>
</motion.button>
))
)}
</AnimatePresence>
</div>
</div>
{/* Undo snackbar */}
<div className="mt-6 h-11">
<AnimatePresence>
{lastRemoved && (
<motion.div
key={lastRemoved.person.id}
initial={prefersReduced ? { opacity: 0 } : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReduced ? { opacity: 0 } : { opacity: 0, y: 10 }}
transition={spring}
className="flex items-center justify-between gap-3 rounded-xl bg-slate-900 px-4 py-2.5 text-sm text-white shadow-lg shadow-slate-900/10 dark:bg-slate-100 dark:text-slate-900"
>
<span className="truncate">
Removed <span className="font-semibold">{lastRemoved.person.name}</span>
</span>
<button
type="button"
onClick={undoRemove}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1 text-sm font-semibold text-sky-300 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 dark:text-sky-600 dark:hover:bg-slate-900/10 dark:focus-visible:ring-sky-600"
>
<UndoIcon />
Undo
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
<p className="sr-only" role="status" aria-live="polite">
{announce}
</p>
</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
Icon Badge
Originalbadges with leading icons

Input Chip
Originalchip input tokens

Filter Chip
Originalselectable filter chips

Cloud Tag
Originalweighted tag cloud

Notification Badge
Originalicon with notification badge

