Line Grow Tab
Original · freetabs where the underline grows in
byWeb InnoventixReact + Tailwind
tablinegrowtabs
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-line-grow.jsontab-line-grow.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Tab = {
key: string;
label: string;
heading: string;
blurb: string;
points: string[];
stat: { value: string; label: string };
};
const TABS: Tab[] = [
{
key: "pipelines",
label: "Pipelines",
heading: "Parallel by default",
blurb:
"Every job starts the moment its dependencies clear — not a step later. Halcyon reads your build graph, schedules across the fleet, and merges results without a serial bottleneck in the middle.",
points: [
"Fan out to 240 concurrent runners",
"Dependency graph inferred from your config",
"Flaky tests quarantined with a retry budget",
],
stat: { value: "3.4×", label: "median pipeline speedup" },
},
{
key: "caching",
label: "Caching",
heading: "Content-addressed caching",
blurb:
"Artifacts are hashed by their inputs, so an untouched module is never rebuilt twice. Restore a warm cache in seconds instead of recompiling the world on every commit.",
points: [
"Remote cache shared across every branch",
"Byte-for-byte reproducible restore keys",
"Scoped eviction keeps hot layers resident",
],
stat: { value: "82%", label: "cache hit rate on main" },
},
{
key: "secrets",
label: "Secrets",
heading: "Secrets that never touch disk",
blurb:
"Credentials are injected into a job's memory at runtime and shredded when it exits. Nothing is written to the workspace, the log stream, or the cache — not even by accident.",
points: [
"Short-lived tokens minted per run",
"Redaction on stdout, stderr, and artifacts",
"Fine-grained scopes for each environment",
],
stat: { value: "0", label: "secrets persisted to disk" },
},
{
key: "insights",
label: "Insights",
heading: "Insights on every run",
blurb:
"See where the time actually goes — per job, per step, per commit. Regressions surface as an alert before they ever land on your default branch.",
points: [
"Flame timeline for the critical path",
"Cost attribution by team and repo",
"Trend alerts wired to Slack and email",
],
stat: { value: "14 days", label: "of retained run history" },
},
];
export default function TabLineGrow() {
const reduceMotion = useReducedMotion();
const uid = useId().replace(/[:]/g, "");
const [active, setActive] = useState(0);
const [indicator, setIndicator] = useState<{ left: number; width: number }>({
left: 0,
width: 0,
});
const listRef = useRef<HTMLDivElement | null>(null);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const measure = useCallback(() => {
const list = listRef.current;
const el = tabRefs.current[active];
if (!list || !el) return;
const listBox = list.getBoundingClientRect();
const elBox = el.getBoundingClientRect();
setIndicator({ left: elBox.left - listBox.left, width: elBox.width });
}, [active]);
useEffect(() => {
measure();
}, [measure]);
useEffect(() => {
const list = listRef.current;
if (!list || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => measure());
ro.observe(list);
return () => ro.disconnect();
}, [measure]);
useEffect(() => {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [measure]);
const focusTab = useCallback((index: number) => {
setActive(index);
tabRefs.current[index]?.focus();
}, []);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const count = TABS.length;
let next: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
next = (active + 1) % count;
break;
case "ArrowLeft":
case "ArrowUp":
next = (active - 1 + count) % count;
break;
case "Home":
next = 0;
break;
case "End":
next = count - 1;
break;
default:
return;
}
e.preventDefault();
focusTab(next);
};
const current = TABS[active];
return (
<section className="relative w-full overflow-hidden bg-slate-50 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes tlg-rise {
0% { opacity: 0; transform: translateY(10px); filter: blur(2px); }
100% { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@keyframes tlg-sheen {
0% { transform: translateX(-140%); }
60%, 100% { transform: translateX(340%); }
}
.tlg-panel { animation: tlg-rise .5s cubic-bezier(.22,.61,.36,1) both; }
.tlg-panel-item { animation: tlg-rise .5s cubic-bezier(.22,.61,.36,1) both; }
.tlg-sheen { animation: tlg-sheen 3s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.tlg-panel, .tlg-panel-item, .tlg-sheen { animation: none !important; filter: none !important; }
}
`}</style>
{/* Ambient backdrop */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10"
>
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] max-w-[90vw] -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,theme(colors.slate.400/0.14)_1px,transparent_0)] [background-size:22px_22px] dark:bg-[radial-gradient(circle_at_1px_1px,theme(colors.slate.500/0.12)_1px,transparent_0)]" />
</div>
<div className="mx-auto w-full max-w-3xl px-6">
<header className="mb-10 max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-300/70 bg-white/60 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-indigo-700 backdrop-blur dark:border-indigo-400/30 dark:bg-white/5 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Halcyon CI
</span>
<h2 className="mt-4 text-pretty text-3xl font-semibold tracking-tight sm:text-4xl">
One pipeline, four things it does exceptionally well.
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Pick a capability. The line grows to meet it.
</p>
</header>
{/* Tablist */}
<div className="relative">
<div
ref={listRef}
role="tablist"
aria-label="Halcyon CI capabilities"
aria-orientation="horizontal"
onKeyDown={onKeyDown}
className="relative flex gap-1 overflow-x-auto pb-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{TABS.map((tab, i) => {
const selected = i === active;
return (
<button
key={tab.key}
ref={(el) => {
tabRefs.current[i] = el;
}}
role="tab"
id={`${uid}-tab-${i}`}
aria-selected={selected}
aria-controls={`${uid}-panel`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(i)}
className={`relative shrink-0 rounded-md px-4 py-2.5 text-sm font-medium outline-none transition-colors duration-200 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-950 ${
selected
? "text-slate-900 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{tab.label}
</button>
);
})}
{/* Static track line */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-slate-200 dark:bg-slate-800"
/>
{/* The growing underline */}
<motion.span
aria-hidden="true"
className="pointer-events-none absolute bottom-0 h-[3px] overflow-hidden rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500 shadow-[0_0_12px_-1px_theme(colors.indigo.500/0.7)] dark:from-indigo-400 dark:via-violet-400 dark:to-indigo-400"
style={{ left: 0, width: 0 }}
initial={false}
animate={{ left: indicator.left, width: indicator.width }}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 360, damping: 32, mass: 0.7 }
}
>
{!reduceMotion && (
<span className="tlg-sheen absolute inset-y-0 -left-1/2 w-1/2 bg-gradient-to-r from-transparent via-white/80 to-transparent" />
)}
</motion.span>
</div>
</div>
{/* Panel */}
<div
key={active}
role="tabpanel"
id={`${uid}-panel`}
aria-labelledby={`${uid}-tab-${active}`}
tabIndex={0}
className="tlg-panel mt-8 rounded-2xl border border-slate-200/80 bg-white/70 p-6 shadow-sm outline-none backdrop-blur-sm 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/60 dark:focus-visible:ring-offset-slate-950"
>
<div className="grid gap-8 sm:grid-cols-[1fr_auto] sm:items-start">
<div>
<h3 className="text-xl font-semibold tracking-tight sm:text-2xl">
{current.heading}
</h3>
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-slate-600 dark:text-slate-300">
{current.blurb}
</p>
<ul className="mt-6 space-y-3">
{current.points.map((point, pi) => (
<li
key={point}
className="tlg-panel-item flex items-start gap-3 text-sm text-slate-700 dark:text-slate-200"
style={{ animationDelay: reduceMotion ? "0ms" : `${120 + pi * 90}ms` }}
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-3 w-3"
>
<path
d="M4 10.5l3.5 3.5L16 5.5"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span>{point}</span>
</li>
))}
</ul>
</div>
{/* Stat card */}
<div
className="tlg-panel-item flex min-w-[10rem] flex-col justify-center rounded-xl border border-slate-200 bg-slate-50/80 p-5 dark:border-slate-800 dark:bg-slate-950/50"
style={{ animationDelay: reduceMotion ? "0ms" : "240ms" }}
>
<span className="bg-gradient-to-br from-indigo-600 to-violet-500 bg-clip-text text-4xl font-bold tabular-nums text-transparent dark:from-indigo-300 dark:to-violet-300">
{current.stat.value}
</span>
<span className="mt-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{current.stat.label}
</span>
</div>
</div>
</div>
{/* Progress dots (echo the active tab) */}
<div className="mt-6 flex items-center gap-2" aria-hidden="true">
{TABS.map((tab, i) => (
<span
key={tab.key}
className={`h-1.5 rounded-full transition-all duration-300 ${
i === active
? "w-6 bg-indigo-500 dark:bg-indigo-400"
: "w-1.5 bg-slate-300 dark:bg-slate-700"
}`}
/>
))}
</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

Pills Tab
Originalpill tabs

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

Closable Tab
Originalbrowser-style closable tabs

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

