Web InnoventixFreeCode

Outline Badge

Original · free

outline badges

byWeb InnoventixReact + Tailwind
badgeoutlinebadges
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-outline.json
badge-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 quote

Similar components

Browse all →