Web InnoventixFreeCode

Removable Badge

Original · free

removable chip badges

byWeb InnoventixReact + Tailwind
badgeremovablebadges
Open

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.json
badge-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 quote

Similar components

Browse all →