Web InnoventixFreeCode

Pills Tab

Original · free

pill tabs

byWeb InnoventixReact + Tailwind
tabpillstabs
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/tab-pills.json
tab-pills.tsx
"use client";

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

type Stat = { value: string; label: string };

type Tab = {
  id: string;
  label: string;
  icon: ReactNode;
  eyebrow: string;
  title: string;
  body: string;
  points?: string[];
  stats?: Stat[];
  live?: string;
};

const TABS: Tab[] = [
  {
    id: "overview",
    label: "Overview",
    eyebrow: "Control plane",
    title: "Everything about your app in one place",
    body: "Build status, traffic, and infrastructure health without switching tools. Every project rolls up into a single dashboard the whole team can read at a glance.",
    stats: [
      { value: "99.98%", label: "uptime, last 90 days" },
      { value: "42ms", label: "median edge latency" },
      { value: "6", label: "regions serving traffic" },
    ],
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className="h-4 w-4"
      >
        <rect x="3" y="3" width="7" height="7" rx="1.5" />
        <rect x="14" y="3" width="7" height="7" rx="1.5" />
        <rect x="3" y="14" width="7" height="7" rx="1.5" />
        <rect x="14" y="14" width="7" height="7" rx="1.5" />
      </svg>
    ),
  },
  {
    id: "deployments",
    label: "Deployments",
    eyebrow: "Deploys",
    title: "Ship on every push, roll back in one click",
    body: "Preview environments spin up for each pull request. Promote to production when checks pass, and revert instantly if a release misbehaves.",
    points: [
      "Immutable build artifacts, addressed by content hash",
      "Atomic swaps for zero-downtime cutovers",
      "Instant rollback to any previous release",
    ],
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className="h-4 w-4"
      >
        <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
        <path d="M12 15 9 12a15.6 15.6 0 0 1 3.5-8C15.5 1.5 19 1.5 21 2c.5 2 .5 5.5-2 8.5A15.6 15.6 0 0 1 12 15Z" />
        <path d="M9 12H4s.5-2.76 2-4c1.36-1.13 4 0 4 0" />
        <path d="M12 15v5s2.76-.5 4-2c1.13-1.36 0-4 0-4" />
      </svg>
    ),
  },
  {
    id: "analytics",
    label: "Analytics",
    eyebrow: "Analytics",
    title: "Real traffic, counted at the edge",
    body: "Privacy-first analytics measures every request without cookies or client-side scripts. Slice by route, country, or device — no sampling, no page-speed tax.",
    live: "1,284 requests/min right now",
    points: [
      "Zero-cookie, GDPR-friendly by design",
      "Per-route and per-region breakdowns",
      "Raw events retained for 12 months",
    ],
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className="h-4 w-4"
      >
        <path d="M3 3v18h18" />
        <rect x="7" y="12" width="3" height="6" rx="0.8" />
        <rect x="12.5" y="8" width="3" height="10" rx="0.8" />
        <rect x="18" y="5" width="3" height="13" rx="0.8" />
      </svg>
    ),
  },
  {
    id: "team",
    label: "Team",
    eyebrow: "Collaboration",
    title: "Access that matches how you work",
    body: "Invite teammates with scoped roles — viewer, developer, or admin. Every deploy, secret change, and permission update lands in the audit log.",
    points: [
      "Single sign-on via SAML and OIDC",
      "Granular, per-project permissions",
      "90-day exportable audit trail",
    ],
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className="h-4 w-4"
      >
        <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
        <circle cx="9" cy="7" r="4" />
        <path d="M22 21v-2a4 4 0 0 0-3-3.87" />
        <path d="M16 3.13a4 4 0 0 1 0 7.75" />
      </svg>
    ),
  },
  {
    id: "security",
    label: "Security",
    eyebrow: "Security",
    title: "Secure by default, not by checklist",
    body: "Automatic TLS, DDoS mitigation, and secrets encrypted at rest. Nothing reaches production without clearing the checks you configure.",
    points: [
      "Managed TLS certificates, auto-renewed",
      "Secrets encrypted with AES-256",
      "SOC 2 Type II compliant infrastructure",
    ],
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className="h-4 w-4"
      >
        <path d="M12 2 4 5v6c0 5 3.4 8.5 8 11 4.6-2.5 8-6 8-11V5Z" />
        <path d="m9 12 2 2 4-4" />
      </svg>
    ),
  },
];

export default function TabPills() {
  const reduce = useReducedMotion();
  const baseId = useId();
  const [active, setActive] = useState<string>(TABS[0].id);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const activeIndex = TABS.findIndex((t) => t.id === active);
  const activeTab = TABS[activeIndex];
  const panelId = `${baseId}-panel`;
  const tabId = (id: string) => `${baseId}-tab-${id}`;

  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
    let next = index;
    if (e.key === "ArrowRight") next = (index + 1) % TABS.length;
    else if (e.key === "ArrowLeft") next = (index - 1 + TABS.length) % TABS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = TABS.length - 1;
    else return;

    e.preventDefault();
    setActive(TABS[next].id);
    tabRefs.current[next]?.focus();
  };

  const spring = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 420, damping: 34 };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tabpills-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.4; transform: scale(1.6); }
        }
        .tabpills-live-dot::after {
          content: "";
          position: absolute;
          inset: 0;
          border-radius: 9999px;
          background: currentColor;
          animation: tabpills-pulse 1.7s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .tabpills-live-dot::after { animation: none; }
        }
      `}</style>

      {/* Ambient backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-32 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
      />

      <div className="relative mx-auto max-w-3xl">
        <header className="mb-10 text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Platform
          </span>
          <h2 className="mt-4 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            One platform, every stage of your app
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-pretty text-base text-slate-600 dark:text-slate-400">
            From the first preview deploy to production traffic at scale — pick a
            surface to see how it fits together.
          </p>
        </header>

        {/* Pill tablist */}
        <div className="flex justify-center">
          <div className="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
            <div
              role="tablist"
              aria-label="Platform capabilities"
              aria-orientation="horizontal"
              className="inline-flex w-max items-center gap-1 rounded-full border border-slate-200 bg-slate-100 p-1.5 dark:border-slate-800 dark:bg-slate-900"
            >
              {TABS.map((tab, index) => {
                const selected = tab.id === active;
                return (
                  <button
                    key={tab.id}
                    ref={(el) => {
                      tabRefs.current[index] = el;
                    }}
                    role="tab"
                    type="button"
                    id={tabId(tab.id)}
                    aria-selected={selected}
                    aria-controls={panelId}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setActive(tab.id)}
                    onKeyDown={(e) => onKeyDown(e, index)}
                    className={[
                      "relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium whitespace-nowrap outline-none transition-colors",
                      "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",
                      selected
                        ? "text-slate-900 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
                    ].join(" ")}
                  >
                    {selected && (
                      <motion.span
                        layoutId={`${baseId}-pill`}
                        transition={spring}
                        aria-hidden="true"
                        className="absolute inset-0 -z-10 rounded-full bg-white shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-700 dark:ring-white/10"
                      />
                    )}
                    <span
                      className={
                        selected
                          ? "text-indigo-600 dark:text-indigo-400"
                          : "text-slate-400 dark:text-slate-500"
                      }
                    >
                      {tab.icon}
                    </span>
                    {tab.label}
                  </button>
                );
              })}
            </div>
          </div>
        </div>

        {/* Panel */}
        <div
          role="tabpanel"
          id={panelId}
          aria-labelledby={tabId(active)}
          tabIndex={0}
          className="relative mt-8 min-h-[19rem] rounded-2xl border border-slate-200 bg-white p-6 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 sm:p-8 dark:border-slate-800 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950"
        >
          <AnimatePresence mode="wait" initial={false}>
            <motion.div
              key={activeTab.id}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
              transition={{ duration: reduce ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
            >
              <p className="text-xs font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
                {activeTab.eyebrow}
              </p>
              <h3 className="mt-2 text-xl font-semibold tracking-tight sm:text-2xl">
                {activeTab.title}
              </h3>
              <p className="mt-3 max-w-2xl text-pretty text-[0.95rem] leading-relaxed text-slate-600 dark:text-slate-400">
                {activeTab.body}
              </p>

              {activeTab.live && (
                <p className="mt-5 inline-flex items-center gap-2.5 rounded-full bg-emerald-50 px-3 py-1.5 text-sm font-medium text-emerald-700 ring-1 ring-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20">
                  <span className="tabpills-live-dot relative inline-block h-2 w-2 rounded-full bg-emerald-500 text-emerald-500 dark:bg-emerald-400 dark:text-emerald-400" />
                  {activeTab.live}
                </p>
              )}

              {activeTab.stats && (
                <dl className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
                  {activeTab.stats.map((stat) => (
                    <div
                      key={stat.label}
                      className="rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/40"
                    >
                      <dt className="sr-only">{stat.label}</dt>
                      <dd>
                        <span className="block text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
                          {stat.value}
                        </span>
                        <span className="mt-1 block text-sm text-slate-500 dark:text-slate-400">
                          {stat.label}
                        </span>
                      </dd>
                    </div>
                  ))}
                </dl>
              )}

              {activeTab.points && (
                <ul className="mt-6 space-y-3">
                  {activeTab.points.map((point) => (
                    <li
                      key={point}
                      className="flex items-start gap-3 text-[0.95rem] text-slate-700 dark:text-slate-300"
                    >
                      <span className="mt-0.5 flex h-5 w-5 flex-none items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400">
                        <svg
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="2.4"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          aria-hidden="true"
                          className="h-3 w-3"
                        >
                          <path d="m5 12 5 5L20 6" />
                        </svg>
                      </span>
                      {point}
                    </li>
                  ))}
                </ul>
              )}
            </motion.div>
          </AnimatePresence>
        </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 →