Web InnoventixFreeCode

Gradient Badge

Original · free

gradient badges

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

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

const CSS = `
@keyframes gbadge-flow { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
@keyframes gbadge-shine { 0% { background-position: 150% 0; } 100% { background-position: -150% 0; } }
.gbadge-flow { animation: gbadge-flow 5s ease infinite; }
.gbadge-shine {
  background-image: linear-gradient(100deg, transparent 25%, rgba(255,255,255,0.55) 50%, transparent 75%);
  background-size: 220% 100%;
  animation: gbadge-shine 2.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .gbadge-flow, .gbadge-shine { animation: none !important; }
}
`;

type Grad = { from: string; to: string; solid: string; shadow: string };

const G = {
  iv: { from: "from-indigo-500", to: "to-violet-500", solid: "text-indigo-500", shadow: "shadow-indigo-500/40" },
  si: { from: "from-sky-500", to: "to-indigo-500", solid: "text-sky-500", shadow: "shadow-sky-500/40" },
  vr: { from: "from-violet-500", to: "to-rose-500", solid: "text-violet-500", shadow: "shadow-violet-500/40" },
  se: { from: "from-sky-500", to: "to-emerald-500", solid: "text-emerald-600", shadow: "shadow-emerald-500/40" },
  ar: { from: "from-amber-500", to: "to-rose-500", solid: "text-amber-600", shadow: "shadow-amber-500/40" },
  es: { from: "from-emerald-500", to: "to-sky-500", solid: "text-emerald-600", shadow: "shadow-emerald-500/40" },
  is: { from: "from-indigo-500", to: "to-sky-500", solid: "text-indigo-500", shadow: "shadow-indigo-500/40" },
  ra: { from: "from-rose-500", to: "to-amber-500", solid: "text-rose-500", shadow: "shadow-rose-500/40" },
  ev: { from: "from-emerald-500", to: "to-violet-500", solid: "text-emerald-600", shadow: "shadow-emerald-500/40" },
  rv: { from: "from-rose-500", to: "to-violet-500", solid: "text-rose-500", shadow: "shadow-rose-500/40" },
} satisfies Record<string, Grad>;

const BADGES: { id: string; label: string; g: Grad }[] = [
  { id: "react", label: "React 19", g: G.iv },
  { id: "typescript", label: "TypeScript 5.6", g: G.si },
  { id: "motion", label: "Motion", g: G.vr },
  { id: "tailwind", label: "Tailwind v4", g: G.se },
  { id: "vite", label: "Vite 6", g: G.ar },
  { id: "node", label: "Node 22 LTS", g: G.es },
  { id: "wcag", label: "WCAG 2.2 AA", g: G.is },
  { id: "graphql", label: "GraphQL", g: G.ra },
  { id: "playwright", label: "Playwright", g: G.ev },
  { id: "radix", label: "Radix UI", g: G.rv },
  { id: "zod", label: "Zod", g: G.is },
  { id: "postgres", label: "PostgreSQL 17", g: G.se },
];

type Variant = "filled" | "outline" | "glow" | "animated";

const VARIANTS: { id: Variant; label: string; icon: (p: { className?: string }) => ReactElement }[] = [
  { id: "filled", label: "Filled", icon: FilledIcon },
  { id: "outline", label: "Outline", icon: OutlineIcon },
  { id: "glow", label: "Glow", icon: GlowIcon },
  { id: "animated", label: "Animated", icon: AnimatedIcon },
];

function styleFor(selected: boolean, variant: Variant, g: Grad) {
  const surfaceBase = "flex items-center gap-1.5 px-3.5 py-1.5";
  if (!selected) {
    return {
      wrap:
        "rounded-full bg-slate-100 ring-1 ring-inset ring-slate-200 transition-colors hover:bg-slate-200 dark:bg-slate-800/70 dark:ring-slate-700 dark:hover:bg-slate-800",
      surface: surfaceBase,
      text: "text-slate-600 dark:text-slate-300",
      check: "text-slate-400 dark:text-slate-500",
      glow: false,
      animated: false,
    };
  }
  switch (variant) {
    case "outline":
      return {
        wrap: `rounded-full bg-gradient-to-r p-[1.5px] transition-opacity hover:opacity-90 ${g.from} ${g.to}`,
        surface: `${surfaceBase} rounded-full bg-white dark:bg-slate-950`,
        text: `bg-gradient-to-r bg-clip-text font-semibold text-transparent ${g.from} ${g.to}`,
        check: g.solid,
        glow: false,
        animated: false,
      };
    case "glow":
      return {
        wrap: "relative rounded-full",
        surface: `relative z-10 rounded-full bg-gradient-to-r shadow-lg transition ${surfaceBase} ${g.from} ${g.to} ${g.shadow} hover:brightness-110`,
        text: "font-semibold text-white",
        check: "text-white",
        glow: true,
        animated: false,
      };
    case "animated":
      return {
        wrap: `relative overflow-hidden rounded-full bg-gradient-to-r bg-[length:200%_100%] transition hover:brightness-110 gbadge-flow ${g.from} ${g.to}`,
        surface: `relative z-10 ${surfaceBase}`,
        text: "font-semibold text-white",
        check: "text-white",
        glow: false,
        animated: true,
      };
    default:
      return {
        wrap: `rounded-full bg-gradient-to-r transition hover:brightness-110 ${g.from} ${g.to}`,
        surface: surfaceBase,
        text: "font-semibold text-white",
        check: "text-white",
        glow: false,
        animated: false,
      };
  }
}

export default function BadgeGradient() {
  const reduce = useReducedMotion();
  const uid = useId();
  const [variant, setVariant] = useState<Variant>("filled");
  const [selected, setSelected] = useState<string[]>(["react", "typescript", "motion", "tailwind"]);
  const variantRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const count = selected.length;

  function toggle(id: string) {
    setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
  }

  function onVariantKey(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % VARIANTS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + VARIANTS.length) % VARIANTS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = VARIANTS.length - 1;
    else return;
    e.preventDefault();
    setVariant(VARIANTS[next].id);
    variantRefs.current[next]?.focus();
  }

  return (
    <section className="relative w-full overflow-hidden bg-white px-4 py-20 dark:bg-slate-950 sm:px-6 sm:py-28 lg:px-8">
      <style>{CSS}</style>

      <div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-gradient-to-br from-indigo-500/20 to-violet-500/10 blur-3xl" />
        <div className="absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-gradient-to-tr from-sky-500/20 to-emerald-500/10 blur-3xl" />
      </div>

      <div className="relative mx-auto w-full max-w-5xl">
        <header className="mb-10 max-w-2xl">
          <span className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
            <SparkleIcon className="h-3.5 w-3.5" />
            Badges
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Gradient badges
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Choose a treatment, then tag your stack. Every chip is a genuine toggle &mdash; keyboard-navigable,
            screen-reader labelled, and rendered across four gradient styles for light and dark.
          </p>
        </header>

        <div className="rounded-3xl border border-slate-200 bg-slate-50/60 p-6 dark:border-slate-800 dark:bg-slate-900/40 sm:p-8">
          <div className="flex flex-col gap-4 border-b border-slate-200 pb-6 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <p
                id={`${uid}-style`}
                className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
              >
                Style
              </p>
              <div
                role="radiogroup"
                aria-labelledby={`${uid}-style`}
                className="inline-flex rounded-full bg-slate-200/70 p-1 dark:bg-slate-800/70"
              >
                {VARIANTS.map((v, i) => {
                  const active = v.id === variant;
                  const Icon = v.icon;
                  return (
                    <button
                      key={v.id}
                      ref={(el) => {
                        variantRefs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      tabIndex={active ? 0 : -1}
                      onClick={() => setVariant(v.id)}
                      onKeyDown={(e) => onVariantKey(e, i)}
                      className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900 ${
                        active
                          ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                          : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
                      }`}
                    >
                      <Icon className="h-4 w-4" />
                      {v.label}
                    </button>
                  );
                })}
              </div>
            </div>

            <div className="flex items-center gap-4">
              <div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
                <span className="relative inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 font-semibold tabular-nums text-white">
                  <AnimatePresence initial={false} mode="popLayout">
                    <motion.span
                      key={count}
                      initial={reduce ? false : { y: "120%", opacity: 0 }}
                      animate={{ y: 0, opacity: 1 }}
                      exit={reduce ? { opacity: 0 } : { y: "-120%", opacity: 0 }}
                      transition={{ duration: 0.24, ease: "easeOut" }}
                    >
                      {count}
                    </motion.span>
                  </AnimatePresence>
                </span>
                selected
              </div>
              <div className="flex items-center gap-1 text-sm">
                <button
                  type="button"
                  onClick={() => setSelected(BADGES.map((b) => b.id))}
                  className="rounded-md px-2 py-1 font-medium text-slate-600 outline-none transition hover:bg-slate-200 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-300 dark:hover:bg-slate-800"
                >
                  All
                </button>
                <button
                  type="button"
                  onClick={() => setSelected([])}
                  className="rounded-md px-2 py-1 font-medium text-slate-600 outline-none transition hover:bg-slate-200 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-300 dark:hover:bg-slate-800"
                >
                  Clear
                </button>
              </div>
            </div>
          </div>

          <div role="group" aria-label="Project technologies" className="flex flex-wrap gap-2.5 pt-6 sm:gap-3">
            {BADGES.map((b, i) => {
              const isSel = selected.includes(b.id);
              const s = styleFor(isSel, variant, b.g);
              return (
                <motion.button
                  key={b.id}
                  type="button"
                  aria-pressed={isSel}
                  onClick={() => toggle(b.id)}
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.3, delay: reduce ? 0 : i * 0.03 }}
                  whileHover={reduce ? undefined : { y: -1 }}
                  whileTap={reduce ? undefined : { scale: 0.96 }}
                  className={`group relative inline-flex select-none text-sm outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900 ${s.wrap}`}
                >
                  {s.glow && (
                    <span
                      aria-hidden
                      className={`pointer-events-none absolute -inset-[3px] rounded-full bg-gradient-to-r opacity-70 blur-md ${b.g.from} ${b.g.to}`}
                    />
                  )}
                  {s.animated && <span aria-hidden className="gbadge-shine pointer-events-none absolute inset-0" />}
                  <span className={s.surface}>
                    <span className={s.text}>{b.label}</span>
                    {isSel ? (
                      <CheckIcon className={`h-3.5 w-3.5 ${s.check}`} />
                    ) : (
                      <PlusIcon className={`h-3.5 w-3.5 ${s.check}`} />
                    )}
                  </span>
                </motion.button>
              );
            })}
          </div>

          <p className="mt-6 text-xs text-slate-500 dark:text-slate-400">
            Tab to a chip and press{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1 py-0.5 font-mono text-[10px] text-slate-700 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">
              Space
            </kbd>{" "}
            to toggle. Use the arrow keys inside the style switcher.
          </p>
          <p aria-live="polite" className="sr-only">
            {count} of {BADGES.length} technologies selected. Style: {variant}.
          </p>
        </div>

        <div className="mt-10">
          <p className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
            In context
          </p>
          <div className="flex flex-wrap items-center gap-2.5">
            <span className="inline-flex items-center rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 px-3 py-1 text-xs font-semibold text-white">
              New
            </span>
            <span className="inline-flex items-center rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 p-[1.5px] text-xs font-semibold">
              <span className="rounded-full bg-white px-3 py-0.5 dark:bg-slate-950">
                <span className="bg-gradient-to-r from-sky-500 to-indigo-500 bg-clip-text text-transparent">Beta</span>
              </span>
            </span>
            <span className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-emerald-500 to-sky-500 px-3 py-1 text-xs font-semibold text-white">
              <span className="relative flex h-1.5 w-1.5">
                <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75" />
                <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-white" />
              </span>
              Live
            </span>
            <span className="inline-flex items-center rounded-full bg-gradient-to-r from-amber-500 to-rose-500 px-3 py-1 text-xs font-semibold text-white shadow-lg shadow-rose-500/30">
              Pro
            </span>
            <span className="inline-flex items-center rounded-full bg-gradient-to-r from-rose-500 to-amber-500 px-3 py-1 text-xs font-semibold text-white">
              Save 40%
            </span>
          </div>
        </div>
      </div>
    </section>
  );
}

function FilledIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
      <rect x="2" y="2" width="12" height="12" rx="4" />
    </svg>
  );
}

function OutlineIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
      <rect x="2.8" y="2.8" width="10.4" height="10.4" rx="3.4" />
    </svg>
  );
}

function GlowIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <circle cx="8" cy="8" r="3" fill="currentColor" stroke="none" />
      <path d="M8 1v1.6M8 13.4V15M1 8h1.6M13.4 8H15M3 3l1.1 1.1M11.9 11.9 13 13M13 3l-1.1 1.1M4.1 11.9 3 13" />
    </svg>
  );
}

function AnimatedIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
      <path d="M9 1 3 9h3.5L7 15l6-8H9.3L9 1z" />
    </svg>
  );
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m3.5 8.5 3 3 6-7" />
    </svg>
  );
}

function PlusIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <path d="M8 3.5v9M3.5 8h9" />
    </svg>
  );
}

function SparkleIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
      <path d="M8 0.5 9.4 6.6 15.5 8 9.4 9.4 8 15.5 6.6 9.4 0.5 8 6.6 6.6 8 0.5z" />
    </svg>
  );
}

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 →