Pills Tab
Original · freepill tabs
byWeb InnoventixReact + Tailwind
tabpillstabs
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.jsontab-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 quoteSimilar components
Browse all →
Underline Tab
Originalunderline tabs with sliding indicator

Boxed Tab
Originalboxed/segmented tabs

Vertical Tab
Originalvertical tabs
Icons Tab
Originaltabs with icons

Segmented Tab
Originalsegmented control tabs

Scrollable Tab
Originalhorizontally scrollable tabs

Animated Indicator Tab
Originaltabs with a morphing indicator

Cards Tab
Originalcard-style tabs with panels

With Badge Tab
Originaltabs with count badges

Line Grow Tab
Originaltabs where the underline grows in

Closable Tab
Originalbrowser-style closable tabs

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

