Web InnoventixFreeCode

Line Grow Tab

Original · free

tabs where the underline grows in

byWeb InnoventixReact + Tailwind
tablinegrowtabs
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-line-grow.json
tab-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 quote

Similar components

Browse all →