Web InnoventixFreeCode

Pill Badge

Original · free

soft pill badges

byWeb InnoventixReact + Tailwind
badgepillbadges
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-pill.json
badge-pill.tsx
"use client";

import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { Transition } from "motion/react";

type Accent = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";

type Tag = {
  id: string;
  label: string;
  accent: Accent;
};

type PresetTopic = Tag & { count: string };

const SOFT: Record<Accent, string> = {
  indigo:
    "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/25",
  violet:
    "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/25",
  emerald:
    "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
  rose: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
  amber:
    "bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
  sky: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
};

const DOT: Record<Accent, string> = {
  indigo: "bg-indigo-500",
  violet: "bg-violet-500",
  emerald: "bg-emerald-500",
  rose: "bg-rose-500",
  amber: "bg-amber-500",
  sky: "bg-sky-500",
};

const REMOVE_HOVER: Record<Accent, string> = {
  indigo:
    "hover:bg-indigo-100 focus-visible:ring-indigo-500 dark:hover:bg-indigo-400/20 dark:focus-visible:ring-indigo-400",
  violet:
    "hover:bg-violet-100 focus-visible:ring-violet-500 dark:hover:bg-violet-400/20 dark:focus-visible:ring-violet-400",
  emerald:
    "hover:bg-emerald-100 focus-visible:ring-emerald-500 dark:hover:bg-emerald-400/20 dark:focus-visible:ring-emerald-400",
  rose: "hover:bg-rose-100 focus-visible:ring-rose-500 dark:hover:bg-rose-400/20 dark:focus-visible:ring-rose-400",
  amber:
    "hover:bg-amber-100 focus-visible:ring-amber-500 dark:hover:bg-amber-400/20 dark:focus-visible:ring-amber-400",
  sky: "hover:bg-sky-100 focus-visible:ring-sky-500 dark:hover:bg-sky-400/20 dark:focus-visible:ring-sky-400",
};

const ACCENTS: Accent[] = ["indigo", "violet", "emerald", "rose", "amber", "sky"];

const PRESETS: PresetTopic[] = [
  { id: "design-systems", label: "Design systems", accent: "violet", count: "48" },
  { id: "typescript", label: "TypeScript", accent: "sky", count: "132" },
  { id: "accessibility", label: "Accessibility", accent: "emerald", count: "27" },
  { id: "motion", label: "Motion design", accent: "rose", count: "19" },
  { id: "performance", label: "Performance", accent: "amber", count: "61" },
  { id: "css-architecture", label: "CSS architecture", accent: "indigo", count: "40" },
  { id: "web-apis", label: "Web APIs", accent: "sky", count: "23" },
  { id: "testing", label: "Testing", accent: "emerald", count: "35" },
];

function CheckIcon() {
  return (
    <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
      <path
        d="M4.5 10.5l3.2 3.2 7.8-7.8"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

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>
  );
}

export default function BadgePill() {
  const prefersReduced = useReducedMotion();
  const counter = useRef(0);
  const [draft, setDraft] = useState("");
  const [selected, setSelected] = useState<Tag[]>([
    { id: "design-systems", label: "Design systems", accent: "violet" },
    { id: "typescript", label: "TypeScript", accent: "sky" },
    { id: "accessibility", label: "Accessibility", accent: "emerald" },
  ]);

  const spring: Transition = prefersReduced
    ? { duration: 0 }
    : { type: "spring", stiffness: 480, damping: 32, mass: 0.7 };

  const isActive = (id: string) => selected.some((t) => t.id === id);

  function toggle(topic: PresetTopic) {
    setSelected((prev) =>
      prev.some((t) => t.id === topic.id)
        ? prev.filter((t) => t.id !== topic.id)
        : [...prev, { id: topic.id, label: topic.label, accent: topic.accent }],
    );
  }

  function remove(id: string) {
    setSelected((prev) => prev.filter((t) => t.id !== id));
  }

  function addCustom(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const label = draft.trim();
    if (!label) return;
    const exists = selected.some(
      (t) => t.label.toLowerCase() === label.toLowerCase(),
    );
    if (exists) {
      setDraft("");
      return;
    }
    const accent = ACCENTS[counter.current % ACCENTS.length];
    counter.current += 1;
    setSelected((prev) => [
      ...prev,
      { id: `custom-${counter.current}-${label}`, label, accent },
    ]);
    setDraft("");
  }

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes bdgpill-ping {
          0% { transform: scale(1); opacity: 0.55; }
          75%, 100% { transform: scale(2.3); opacity: 0; }
        }
        .bdgpill-ping { animation: bdgpill-ping 1.9s cubic-bezier(0,0,0.2,1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .bdgpill-ping { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <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-10">
            {/* Header */}
            <div className="flex flex-wrap items-start justify-between gap-4">
              <div className="max-w-md">
                <span className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-slate-600 ring-1 ring-slate-200 ring-inset dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700">
                  Preferences
                </span>
                <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-[1.7rem] dark:text-white">
                  Curate your feed
                </h2>
                <p className="mt-2 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
                  Pick the topics you want to follow. Add your own to build a
                  reading list that stays sharp.
                </p>
              </div>

              <span className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1.5 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="relative flex h-2 w-2">
                  <span className="bdgpill-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>
                12 trending this week
              </span>
            </div>

            {/* Toggle group */}
            <div className="mt-8">
              <p className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Browse topics
              </p>
              <div
                role="group"
                aria-label="Available topics"
                className="mt-3 flex flex-wrap gap-2.5"
              >
                {PRESETS.map((topic) => {
                  const active = isActive(topic.id);
                  return (
                    <button
                      key={topic.id}
                      type="button"
                      aria-pressed={active}
                      onClick={() => toggle(topic)}
                      className={[
                        "group inline-flex items-center gap-2 rounded-full px-3.5 py-2 text-sm font-medium ring-1 ring-inset transition-colors duration-200",
                        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-slate-900 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900",
                        active
                          ? SOFT[topic.accent]
                          : "bg-white text-slate-600 ring-slate-200 hover:bg-slate-50 hover:text-slate-900 dark:bg-slate-800/60 dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-800",
                      ].join(" ")}
                    >
                      <span
                        className={[
                          "grid h-4 w-4 place-items-center rounded-full transition-colors",
                          active
                            ? "text-current"
                            : "text-slate-400 dark:text-slate-500",
                        ].join(" ")}
                        aria-hidden="true"
                      >
                        {active ? <CheckIcon /> : <PlusIcon />}
                      </span>
                      {topic.label}
                      <span
                        className={[
                          "rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums transition-colors",
                          active
                            ? "bg-white/60 dark:bg-white/10"
                            : "bg-slate-100 text-slate-400 dark:bg-slate-700/60 dark:text-slate-400",
                        ].join(" ")}
                      >
                        {topic.count}
                      </span>
                    </button>
                  );
                })}
              </div>
            </div>

            {/* Divider */}
            <div className="my-8 h-px w-full bg-slate-100 dark:bg-slate-800" />

            {/* Selected pills */}
            <div className="flex items-center justify-between gap-4">
              <p className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Your selection
              </p>
              <span className="text-xs font-medium tabular-nums text-slate-400 dark:text-slate-500">
                {selected.length} {selected.length === 1 ? "topic" : "topics"}
              </span>
            </div>

            <ul
              role="list"
              aria-live="polite"
              className="mt-3 flex min-h-[3rem] flex-wrap content-start gap-2 rounded-2xl bg-slate-50 p-3 ring-1 ring-slate-100 ring-inset dark:bg-slate-800/40 dark:ring-slate-800"
            >
              <AnimatePresence initial={false} mode="popLayout">
                {selected.length === 0 ? (
                  <motion.li
                    key="empty"
                    layout={!prefersReduced}
                    initial={prefersReduced ? undefined : { opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={prefersReduced ? undefined : { opacity: 0 }}
                    transition={spring}
                    className="px-2 py-1.5 text-sm text-slate-400 dark:text-slate-500"
                  >
                    No topics yet — choose a few above or add your own.
                  </motion.li>
                ) : (
                  selected.map((tag) => (
                    <motion.li
                      key={tag.id}
                      layout={!prefersReduced}
                      initial={
                        prefersReduced ? undefined : { opacity: 0, scale: 0.7 }
                      }
                      animate={{ opacity: 1, scale: 1 }}
                      exit={
                        prefersReduced
                          ? undefined
                          : { opacity: 0, scale: 0.7 }
                      }
                      transition={spring}
                      className={[
                        "inline-flex items-center gap-1.5 rounded-full py-1 pl-2.5 pr-1 text-sm font-medium ring-1 ring-inset",
                        SOFT[tag.accent],
                      ].join(" ")}
                    >
                      <span
                        className={[
                          "h-1.5 w-1.5 rounded-full",
                          DOT[tag.accent],
                        ].join(" ")}
                        aria-hidden="true"
                      />
                      {tag.label}
                      <button
                        type="button"
                        onClick={() => remove(tag.id)}
                        aria-label={`Remove ${tag.label}`}
                        className={[
                          "grid h-5 w-5 place-items-center rounded-full text-current opacity-80 transition hover:opacity-100 focus-visible:outline-none focus-visible:ring-2",
                          REMOVE_HOVER[tag.accent],
                        ].join(" ")}
                      >
                        <CloseIcon />
                      </button>
                    </motion.li>
                  ))
                )}
              </AnimatePresence>
            </ul>

            {/* Add custom + reset */}
            <form
              onSubmit={addCustom}
              className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center"
            >
              <div className="relative flex-1">
                <label htmlFor="bdgpill-add" className="sr-only">
                  Add a custom topic
                </label>
                <input
                  id="bdgpill-add"
                  type="text"
                  value={draft}
                  onChange={(e) => setDraft(e.target.value)}
                  placeholder="Add a topic, e.g. WebGL"
                  maxLength={32}
                  className="w-full rounded-full border-0 bg-white py-2.5 pl-4 pr-4 text-sm text-slate-900 ring-1 ring-slate-200 ring-inset transition placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 dark:bg-slate-800/60 dark:text-white dark:ring-slate-700 dark:placeholder:text-slate-500 dark:focus:ring-white"
                />
              </div>
              <div className="flex items-center gap-2">
                <button
                  type="submit"
                  disabled={draft.trim().length === 0}
                  className="inline-flex items-center gap-1.5 rounded-full bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
                >
                  <PlusIcon />
                  Add
                </button>
                <button
                  type="button"
                  onClick={() => setSelected([])}
                  disabled={selected.length === 0}
                  className="rounded-full px-4 py-2.5 text-sm font-medium text-slate-500 transition hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-400 dark:hover:text-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
                >
                  Clear all
                </button>
              </div>
            </form>
          </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 quote

Similar components

Browse all →