Web InnoventixFreeCode

Basic Badge

Original · free

basic badges across colours

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

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

type Variant = "solid" | "soft" | "outline";
type Tone =
  | "emerald"
  | "sky"
  | "indigo"
  | "violet"
  | "amber"
  | "rose"
  | "slate"
  | "zinc";

type Filter = { id: string; tone: Tone; label: string };

const TONES: readonly Tone[] = [
  "emerald",
  "sky",
  "indigo",
  "violet",
  "amber",
  "rose",
  "slate",
  "zinc",
];

const TONE_LABEL: Record<Tone, string> = {
  emerald: "Shipped",
  sky: "In review",
  indigo: "Planned",
  violet: "Beta",
  amber: "Needs QA",
  rose: "Blocked",
  slate: "Draft",
  zinc: "Archived",
};

const SOLID: Record<Tone, string> = {
  emerald: "bg-emerald-600 text-white dark:bg-emerald-500",
  sky: "bg-sky-600 text-white dark:bg-sky-500",
  indigo: "bg-indigo-600 text-white dark:bg-indigo-500",
  violet: "bg-violet-600 text-white dark:bg-violet-500",
  amber: "bg-amber-400 text-amber-950 dark:bg-amber-400 dark:text-amber-950",
  rose: "bg-rose-600 text-white dark:bg-rose-500",
  slate: "bg-slate-700 text-white dark:bg-slate-200 dark:text-slate-900",
  zinc: "bg-zinc-700 text-white dark:bg-zinc-200 dark:text-zinc-900",
};

const SOFT: Record<Tone, string> = {
  emerald:
    "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  sky: "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  indigo:
    "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  violet:
    "bg-violet-50 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
  amber: "bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  rose: "bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
  slate: "bg-slate-100 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300",
  zinc: "bg-zinc-100 text-zinc-700 dark:bg-zinc-400/15 dark:text-zinc-300",
};

const OUTLINE: Record<Tone, string> = {
  emerald:
    "border border-emerald-300 text-emerald-700 dark:border-emerald-500/40 dark:text-emerald-300",
  sky: "border border-sky-300 text-sky-700 dark:border-sky-500/40 dark:text-sky-300",
  indigo:
    "border border-indigo-300 text-indigo-700 dark:border-indigo-500/40 dark:text-indigo-300",
  violet:
    "border border-violet-300 text-violet-700 dark:border-violet-500/40 dark:text-violet-300",
  amber:
    "border border-amber-400 text-amber-700 dark:border-amber-500/40 dark:text-amber-300",
  rose: "border border-rose-300 text-rose-700 dark:border-rose-500/40 dark:text-rose-300",
  slate:
    "border border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300",
  zinc: "border border-zinc-300 text-zinc-700 dark:border-zinc-600 dark:text-zinc-300",
};

const DOT_TONE: Record<Tone, string> = {
  emerald: "bg-emerald-500",
  sky: "bg-sky-500",
  indigo: "bg-indigo-500",
  violet: "bg-violet-500",
  amber: "bg-amber-500",
  rose: "bg-rose-500",
  slate: "bg-slate-500",
  zinc: "bg-zinc-500",
};

const VARIANTS: readonly { id: Variant; label: string }[] = [
  { id: "solid", label: "Solid" },
  { id: "soft", label: "Soft" },
  { id: "outline", label: "Outline" },
];

const INITIAL_FILTERS: readonly Filter[] = [
  { id: "priority", tone: "rose", label: "Priority: High" },
  { id: "team", tone: "indigo", label: "Team: Platform" },
  { id: "label", tone: "violet", label: "Label: Accessibility" },
  { id: "milestone", tone: "sky", label: "Milestone: v2.4" },
  { id: "status", tone: "emerald", label: "Status: Ready" },
  { id: "type", tone: "amber", label: "Type: Bug" },
];

const KEYFRAMES = `
@keyframes badgebasic_ping {
  0% { transform: scale(1); opacity: 0.55; }
  75%, 100% { transform: scale(2.4); opacity: 0; }
}
@keyframes badgebasic_pop {
  from { opacity: 0; transform: translateY(5px) scale(0.96); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
.badgebasic-ping { animation: badgebasic_ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite; }
.badgebasic-pop { animation: badgebasic_pop 0.35s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .badgebasic-ping, .badgebasic-pop { animation: none !important; }
}
`;

function badgeClasses(variant: Variant, tone: Tone): string {
  if (variant === "solid") return SOLID[tone];
  if (variant === "soft") return SOFT[tone];
  return OUTLINE[tone];
}

function dotClasses(variant: Variant, tone: Tone): string {
  return variant === "solid" ? "bg-current opacity-80" : DOT_TONE[tone];
}

export default function BadgeBasic() {
  const reduce = useReducedMotion();
  const headingId = useId();
  const paletteId = useId();
  const filtersId = useId();

  const [variant, setVariant] = useState<Variant>("soft");
  const [showDot, setShowDot] = useState(true);
  const [filters, setFilters] = useState<Filter[]>([...INITIAL_FILTERS]);

  const variantRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const handleVariantKey = (
    event: ReactKeyboardEvent<HTMLButtonElement>,
    index: number,
  ) => {
    const count = VARIANTS.length;
    let next = index;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      next = (index + 1) % count;
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      next = (index - 1 + count) % count;
    } else if (event.key === "Home") {
      next = 0;
    } else if (event.key === "End") {
      next = count - 1;
    } else {
      return;
    }
    event.preventDefault();
    setVariant(VARIANTS[next].id);
    variantRefs.current[next]?.focus();
  };

  const removeFilter = (id: string) =>
    setFilters((prev) => prev.filter((item) => item.id !== id));

  const resetFilters = () => setFilters([...INITIAL_FILTERS]);
  const isFull = filters.length === INITIAL_FILTERS.length;

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500";

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full bg-slate-50 px-6 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
    >
      <style>{KEYFRAMES}</style>

      <div className="mx-auto max-w-4xl">
        <header className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Design system
          </p>
          <h2
            id={headingId}
            className="mt-3 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white"
          >
            Status badges
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A basic badge set spanning eight colours in solid, soft, and
            outline styles. Switch the controls to preview every variant live,
            then dismiss filters to try the interactive tag list.
          </p>
        </header>

        {/* Controls */}
        <div className="mt-10 flex flex-col gap-6 rounded-2xl border border-slate-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-wrap items-center gap-6">
            <div
              role="radiogroup"
              aria-label="Badge style"
              className="relative inline-flex rounded-xl bg-slate-100 p-1 dark:bg-slate-800"
            >
              {VARIANTS.map((item, index) => {
                const active = variant === item.id;
                return (
                  <button
                    key={item.id}
                    ref={(el) => {
                      variantRefs.current[index] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    tabIndex={active ? 0 : -1}
                    onClick={() => setVariant(item.id)}
                    onKeyDown={(event) => handleVariantKey(event, index)}
                    className={`relative z-10 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
                      active
                        ? "text-slate-900 dark:text-white"
                        : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {active && (
                      <motion.span
                        layoutId="badgebasic-seg"
                        aria-hidden="true"
                        className="absolute inset-0 -z-10 rounded-lg bg-white shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-950 dark:ring-white/10"
                        transition={
                          reduce
                            ? { duration: 0 }
                            : { type: "spring", stiffness: 500, damping: 38 }
                        }
                      />
                    )}
                    {item.label}
                  </button>
                );
              })}
            </div>

            <button
              type="button"
              role="switch"
              aria-checked={showDot}
              onClick={() => setShowDot((value) => !value)}
              className={`group inline-flex items-center gap-2.5 rounded-full ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
            >
              <span
                aria-hidden="true"
                className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
                  showDot
                    ? "bg-indigo-600 dark:bg-indigo-500"
                    : "bg-slate-300 dark:bg-slate-600"
                }`}
              >
                <span
                  className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
                    showDot ? "left-[1.125rem]" : "left-0.5"
                  }`}
                />
              </span>
              <span className="text-sm font-medium text-slate-700 dark:text-slate-300">
                Status dot
              </span>
            </button>
          </div>

          {/* Live indicator example */}
          <span className="inline-flex items-center gap-2 self-start rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 sm:self-auto dark:bg-emerald-500/15 dark:text-emerald-300">
            <span className="relative flex h-2 w-2">
              <span
                aria-hidden="true"
                className="badgebasic-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>
            342 online
          </span>
        </div>

        {/* Palette */}
        <section aria-labelledby={paletteId} className="mt-10">
          <h3
            id={paletteId}
            className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
          >
            {VARIANTS.find((item) => item.id === variant)?.label} palette
          </h3>
          <ul key={variant} className="mt-4 flex flex-wrap gap-2.5">
            {TONES.map((tone, index) => (
              <li
                key={tone}
                className="badgebasic-pop"
                style={{ animationDelay: `${index * 40}ms` }}
              >
                <span
                  className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${badgeClasses(
                    variant,
                    tone,
                  )}`}
                >
                  {showDot && (
                    <span
                      aria-hidden="true"
                      className={`h-1.5 w-1.5 rounded-full ${dotClasses(
                        variant,
                        tone,
                      )}`}
                    />
                  )}
                  {TONE_LABEL[tone]}
                </span>
              </li>
            ))}
          </ul>
        </section>

        {/* Interactive removable tags */}
        <section
          aria-labelledby={filtersId}
          className="mt-10 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900"
        >
          <div className="flex items-center justify-between gap-4">
            <div className="flex items-center gap-2.5">
              <h3
                id={filtersId}
                className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
              >
                Applied filters
              </h3>
              <span className="inline-flex min-w-5 items-center justify-center rounded-full bg-slate-200 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-300">
                {filters.length}
              </span>
            </div>
            <button
              type="button"
              onClick={resetFilters}
              disabled={isFull}
              className={`rounded-lg px-2.5 py-1 text-sm font-medium text-indigo-600 transition-colors enabled:hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-40 dark:text-indigo-400 dark:enabled:hover:bg-indigo-500/10 ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
            >
              Reset
            </button>
          </div>

          <span className="sr-only" aria-live="polite">
            {filters.length} filters applied
          </span>

          {filters.length === 0 ? (
            <p className="mt-4 rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
              No filters applied. Press Reset to restore the default set.
            </p>
          ) : (
            <ul className="mt-4 flex flex-wrap gap-2">
              <AnimatePresence initial={false}>
                {filters.map((item) => (
                  <motion.li
                    key={item.id}
                    layout={reduce ? false : true}
                    initial={reduce ? false : { opacity: 0, scale: 0.85 }}
                    animate={reduce ? {} : { opacity: 1, scale: 1 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}
                    transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
                    className={`inline-flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-semibold ${SOFT[item.tone]}`}
                  >
                    <span
                      aria-hidden="true"
                      className={`h-1.5 w-1.5 rounded-full ${DOT_TONE[item.tone]}`}
                    />
                    {item.label}
                    <button
                      type="button"
                      onClick={() => removeFilter(item.id)}
                      aria-label={`Remove ${item.label}`}
                      className={`ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full opacity-70 transition hover:bg-slate-900/10 hover:opacity-100 dark:hover:bg-white/15 ${focusRing}`}
                    >
                      <svg
                        viewBox="0 0 16 16"
                        aria-hidden="true"
                        className="h-3 w-3"
                      >
                        <path
                          d="M4.5 4.5l7 7M11.5 4.5l-7 7"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.75"
                          strokeLinecap="round"
                        />
                      </svg>
                    </button>
                  </motion.li>
                ))}
              </AnimatePresence>
            </ul>
          )}
        </section>
      </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 →