Web InnoventixFreeCode

Icon Badge

Original · free

badges with leading icons

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

import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type BadgeColor =
  | "emerald"
  | "amber"
  | "rose"
  | "indigo"
  | "violet"
  | "sky"
  | "slate";

type BadgeSize = "sm" | "md" | "lg";
type BadgeStyle = "soft" | "solid" | "outline";

type IconName =
  | "flame"
  | "shield"
  | "bolt"
  | "accessibility"
  | "sparkles"
  | "book"
  | "star"
  | "check"
  | "filter"
  | "close"
  | "trend-down";

type Label = {
  id: string;
  name: string;
  icon: IconName;
  color: BadgeColor;
  count: number;
};

const LABELS: ReadonlyArray<Label> = [
  { id: "critical", name: "Critical", icon: "flame", color: "rose", count: 8 },
  { id: "security", name: "Security", icon: "shield", color: "violet", count: 5 },
  { id: "perf", name: "Performance", icon: "bolt", color: "amber", count: 12 },
  { id: "a11y", name: "Accessibility", icon: "accessibility", color: "sky", count: 6 },
  { id: "enhancement", name: "Enhancement", icon: "sparkles", color: "indigo", count: 17 },
  { id: "docs", name: "Documentation", icon: "book", color: "slate", count: 9 },
  { id: "first", name: "Good first issue", icon: "star", color: "emerald", count: 14 },
];

const SOFT: Record<BadgeColor, string> = {
  emerald:
    "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
  amber:
    "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20",
  rose: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20",
  indigo:
    "bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-600/20 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20",
  violet:
    "bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-600/20 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/20",
  sky: "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/20",
  slate:
    "bg-slate-100 text-slate-700 ring-1 ring-inset ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-400/20",
};

const SOLID: Record<BadgeColor, string> = {
  emerald:
    "bg-emerald-600 text-white ring-1 ring-inset ring-emerald-700/40 dark:bg-emerald-500 dark:text-emerald-950 dark:ring-emerald-400/30",
  amber:
    "bg-amber-600 text-white ring-1 ring-inset ring-amber-700/40 dark:bg-amber-400 dark:text-amber-950 dark:ring-amber-300/30",
  rose: "bg-rose-600 text-white ring-1 ring-inset ring-rose-700/40 dark:bg-rose-500 dark:text-rose-950 dark:ring-rose-400/30",
  indigo:
    "bg-indigo-600 text-white ring-1 ring-inset ring-indigo-700/40 dark:bg-indigo-500 dark:text-white dark:ring-indigo-400/30",
  violet:
    "bg-violet-600 text-white ring-1 ring-inset ring-violet-700/40 dark:bg-violet-500 dark:text-white dark:ring-violet-400/30",
  sky: "bg-sky-600 text-white ring-1 ring-inset ring-sky-700/40 dark:bg-sky-500 dark:text-sky-950 dark:ring-sky-400/30",
  slate:
    "bg-slate-700 text-white ring-1 ring-inset ring-slate-800/40 dark:bg-slate-200 dark:text-slate-900 dark:ring-slate-300/30",
};

const OUTLINE: Record<BadgeColor, string> = {
  emerald:
    "bg-transparent text-emerald-700 ring-1 ring-inset ring-emerald-600/40 dark:text-emerald-300 dark:ring-emerald-400/40",
  amber:
    "bg-transparent text-amber-700 ring-1 ring-inset ring-amber-600/40 dark:text-amber-300 dark:ring-amber-400/40",
  rose: "bg-transparent text-rose-700 ring-1 ring-inset ring-rose-600/40 dark:text-rose-300 dark:ring-rose-400/40",
  indigo:
    "bg-transparent text-indigo-700 ring-1 ring-inset ring-indigo-600/40 dark:text-indigo-300 dark:ring-indigo-400/40",
  violet:
    "bg-transparent text-violet-700 ring-1 ring-inset ring-violet-600/40 dark:text-violet-300 dark:ring-violet-400/40",
  sky: "bg-transparent text-sky-700 ring-1 ring-inset ring-sky-600/40 dark:text-sky-300 dark:ring-sky-400/40",
  slate:
    "bg-transparent text-slate-700 ring-1 ring-inset ring-slate-500/40 dark:text-slate-300 dark:ring-slate-400/40",
};

const SIZE_PAD: Record<BadgeSize, string> = {
  sm: "gap-1 px-2 py-0.5 text-xs",
  md: "gap-1.5 px-3 py-1 text-sm",
  lg: "gap-2 px-4 py-1.5 text-[0.95rem]",
};

const ICON_SIZE: Record<BadgeSize, string> = {
  sm: "h-3 w-3",
  md: "h-3.5 w-3.5",
  lg: "h-4 w-4",
};

const SIZE_OPTIONS: ReadonlyArray<{ value: BadgeSize; label: string }> = [
  { value: "sm", label: "Small" },
  { value: "md", label: "Medium" },
  { value: "lg", label: "Large" },
];

const STYLE_OPTIONS: ReadonlyArray<{ value: BadgeStyle; label: string }> = [
  { value: "soft", label: "Soft" },
  { value: "solid", label: "Solid" },
  { value: "outline", label: "Outline" },
];

const FOCUS =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

function Glyph({ name, className }: { name: IconName; className?: string }) {
  const shared = {
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.75,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    "aria-hidden": true,
    className,
  };
  switch (name) {
    case "flame":
      return (
        <svg {...shared}>
          <path d="M12 2.5c2.5 3 4.5 5.2 4.5 8.6a4.5 4.5 0 1 1-9 0c0-1.7.7-2.9 1.8-4 .2 1 .8 1.7 1.7 2 .1-2.4-.5-4.6 1-6.6Z" />
        </svg>
      );
    case "shield":
      return (
        <svg {...shared}>
          <path d="M12 3 5 6v5c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
          <path d="m9.2 12 2 2 3.6-3.6" />
        </svg>
      );
    case "bolt":
      return (
        <svg {...shared}>
          <path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" />
        </svg>
      );
    case "accessibility":
      return (
        <svg {...shared}>
          <circle cx="12" cy="4.4" r="1.5" />
          <path d="M5 8.4c2 .8 4.4 1.2 7 1.2s5-.4 7-1.2M12 9.7V15m0 0-3 6m3-6 3 6" />
        </svg>
      );
    case "sparkles":
      return (
        <svg {...shared}>
          <path d="M12 3.5 13.5 8 18 9.5 13.5 11 12 15.5 10.5 11 6 9.5 10.5 8 12 3.5Z" />
          <path d="M18 14.5 18.8 16.7 21 17.5 18.8 18.3 18 20.5 17.2 18.3 15 17.5 17.2 16.7 18 14.5Z" />
        </svg>
      );
    case "book":
      return (
        <svg {...shared}>
          <path d="M12 6c-1.5-1.2-3.5-2-6-2H4v13h2c2.5 0 4.5.8 6 2m0-13c1.5-1.2 3.5-2 6-2h2v13h-2c-2.5 0-4.5.8-6 2m0-13v13" />
        </svg>
      );
    case "star":
      return (
        <svg {...shared}>
          <path d="m12 3 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.2l5.9-.9L12 3Z" />
        </svg>
      );
    case "check":
      return (
        <svg {...shared}>
          <path d="m5 12.5 4.5 4.5L19 6.5" />
        </svg>
      );
    case "filter":
      return (
        <svg {...shared}>
          <path d="M4 5h16l-6.5 8v5l-3 2v-7L4 5Z" />
        </svg>
      );
    case "close":
      return (
        <svg {...shared}>
          <path d="M6 6l12 12M18 6 6 18" />
        </svg>
      );
    case "trend-down":
      return (
        <svg {...shared}>
          <path d="M3 7l7 7 3.5-3.5L21 17M21 17h-5m5 0v-5" />
        </svg>
      );
    default:
      return null;
  }
}

function Segmented<T extends string>({
  label,
  options,
  value,
  onChange,
}: {
  label: string;
  options: ReadonlyArray<{ value: T; label: string }>;
  value: T;
  onChange: (next: T) => void;
}) {
  const refs = useRef<Array<HTMLButtonElement | null>>([]);

  function handleKey(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    let next = index;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      next = (index + 1) % options.length;
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      next = (index - 1 + options.length) % options.length;
    } else if (event.key === "Home") {
      next = 0;
    } else if (event.key === "End") {
      next = options.length - 1;
    } else {
      return;
    }
    event.preventDefault();
    onChange(options[next].value);
    refs.current[next]?.focus();
  }

  return (
    <div
      role="radiogroup"
      aria-label={label}
      className="inline-flex items-center gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/80"
    >
      {options.map((opt, index) => {
        const active = opt.value === value;
        return (
          <button
            key={opt.value}
            ref={(el) => {
              refs.current[index] = el;
            }}
            type="button"
            role="radio"
            aria-checked={active}
            tabIndex={active ? 0 : -1}
            onClick={() => onChange(opt.value)}
            onKeyDown={(event) => handleKey(event, index)}
            className={[
              "rounded-lg px-3 py-1.5 text-sm font-medium transition",
              FOCUS,
              active
                ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
            ].join(" ")}
          >
            {opt.label}
          </button>
        );
      })}
    </div>
  );
}

export default function BadgeIcon() {
  const reduce = useReducedMotion();
  const summaryId = useId();

  const [size, setSize] = useState<BadgeSize>("md");
  const [variant, setVariant] = useState<BadgeStyle>("soft");
  const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());

  const styleMap = variant === "solid" ? SOLID : variant === "outline" ? OUTLINE : SOFT;

  const { totalIssues, selectedCount, matchingIssues } = useMemo(() => {
    const total = LABELS.reduce((sum, l) => sum + l.count, 0);
    const chosen = LABELS.filter((l) => selected.has(l.id));
    const matching = chosen.reduce((sum, l) => sum + l.count, 0);
    return {
      totalIssues: total,
      selectedCount: chosen.length,
      matchingIssues: chosen.length === 0 ? total : matching,
    };
  }, [selected]);

  function toggle(id: string) {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }

  const anySelected = selectedCount > 0;
  const iconCls = ICON_SIZE[size];

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:py-20 dark:bg-slate-950 dark:text-white">
      <style>{`
@keyframes bi_ping {
  0% { transform: scale(1); opacity: 0.55; }
  75%, 100% { transform: scale(2.4); opacity: 0; }
}
.bi-ping { animation: bi_ping 1.7s cubic-bezier(0, 0, 0.2, 1) infinite; transform-origin: center; }
@media (prefers-reduced-motion: reduce) {
  .bi-ping { animation: none; }
}
      `}</style>

      <div className="mx-auto max-w-4xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
            <Glyph name="filter" className="h-3.5 w-3.5" />
            Triage board
          </span>
          <h2 className="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Filter issues by label
          </h2>
          <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-400">
            Toggle any label to focus the queue. Every badge carries a leading
            glyph so its category reads at a glance — tuned for both light and
            dark, three sizes, and three styles.
          </p>
        </header>

        <div className="rounded-2xl border border-slate-200/80 bg-white/70 p-6 shadow-sm backdrop-blur-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900/60">
          <div className="flex flex-wrap items-end gap-x-8 gap-y-5">
            <div>
              <p className="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">
                Size
              </p>
              <Segmented<BadgeSize>
                label="Badge size"
                options={SIZE_OPTIONS}
                value={size}
                onChange={setSize}
              />
            </div>
            <div>
              <p className="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">
                Style
              </p>
              <Segmented<BadgeStyle>
                label="Badge style"
                options={STYLE_OPTIONS}
                value={variant}
                onChange={setVariant}
              />
            </div>
          </div>

          <div className="my-6 h-px w-full bg-slate-200 dark:bg-slate-800" />

          <div
            role="group"
            aria-label="Issue labels"
            className="flex flex-wrap items-center gap-2.5"
          >
            {LABELS.map((label, index) => {
              const isSelected = selected.has(label.id);
              const appearance = isSelected ? SOLID[label.color] : styleMap[label.color];
              const muted = anySelected && !isSelected;
              return (
                <motion.button
                  key={label.id}
                  type="button"
                  aria-pressed={isSelected}
                  aria-label={`${label.name}, ${label.count} open issues${
                    isSelected ? ", selected" : ""
                  }`}
                  onClick={() => toggle(label.id)}
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: 0.25,
                    delay: reduce ? 0 : index * 0.035,
                    ease: "easeOut",
                  }}
                  className={[
                    "inline-flex items-center rounded-full font-medium transition duration-200 select-none",
                    "hover:brightness-105 active:scale-95 dark:hover:brightness-125",
                    SIZE_PAD[size],
                    appearance,
                    isSelected ? "font-semibold" : "",
                    muted ? "opacity-55 hover:opacity-100" : "",
                    FOCUS,
                  ].join(" ")}
                >
                  <Glyph name={label.icon} className={iconCls} />
                  <span>{label.name}</span>
                  <span className="tabular-nums opacity-70">{label.count}</span>
                  {isSelected ? (
                    <Glyph name="check" className={`${iconCls} -mr-0.5`} />
                  ) : null}
                </motion.button>
              );
            })}
          </div>

          <div className="mt-6 flex flex-wrap items-center justify-between gap-3">
            <p
              id={summaryId}
              role="status"
              aria-live="polite"
              className="text-sm text-slate-600 dark:text-slate-400"
            >
              {selectedCount === 0 ? (
                <>
                  Showing all{" "}
                  <span className="font-semibold text-slate-900 dark:text-white">
                    {LABELS.length}
                  </span>{" "}
                  labels ·{" "}
                  <span className="font-semibold tabular-nums text-slate-900 dark:text-white">
                    {totalIssues}
                  </span>{" "}
                  open issues
                </>
              ) : (
                <>
                  <span className="font-semibold text-slate-900 dark:text-white">
                    {selectedCount}
                  </span>{" "}
                  of {LABELS.length} labels ·{" "}
                  <span className="font-semibold tabular-nums text-slate-900 dark:text-white">
                    {matchingIssues}
                  </span>{" "}
                  matching issues
                </>
              )}
            </p>

            <AnimatePresence>
              {anySelected ? (
                <motion.button
                  key="clear"
                  type="button"
                  onClick={() => setSelected(new Set())}
                  initial={reduce ? false : { opacity: 0, scale: 0.9 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
                  transition={{ duration: 0.18 }}
                  className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium text-slate-600 ring-1 ring-inset ring-slate-300 transition hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-800 dark:hover:text-white ${FOCUS}`}
                >
                  <Glyph name="close" className="h-3.5 w-3.5" />
                  Clear all
                </motion.button>
              ) : null}
            </AnimatePresence>
          </div>
        </div>

        <div className="mt-6 flex flex-wrap items-center gap-2.5">
          <span className="text-xs font-medium text-slate-400 dark:text-slate-500">
            Status variants:
          </span>
          <span className="inline-flex 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-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20">
            <span className="relative flex h-2 w-2" aria-hidden="true">
              <span className="bi-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>
            Deploy live
          </span>
          <span className="inline-flex items-center gap-1 rounded-full bg-violet-600 px-2.5 py-1 text-xs font-semibold text-white ring-1 ring-inset ring-violet-700/40 dark:bg-violet-500 dark:ring-violet-400/30">
            <Glyph name="sparkles" className="h-3.5 w-3.5" />
            Beta
          </span>
          <span className="inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 ring-1 ring-inset ring-rose-600/20 tabular-nums dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20">
            <Glyph name="trend-down" className="h-3.5 w-3.5" />
            -12% load time
          </span>
        </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 →