Web InnoventixFreeCode

Underline Tab

Original · free

underline tabs with sliding indicator

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

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { motion, useReducedMotion } from "motion/react";

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

type Point = { title: string; body: string };

type Tab = {
  id: string;
  label: string;
  heading: string;
  blurb: string;
  stat: { value: string; label: string };
  points: Point[];
};

const TABS: Tab[] = [
  {
    id: "overview",
    label: "Overview",
    heading: "Your whole stack, one calm console",
    blurb:
      "Beacon folds logs, traces, and deploy events into a single timeline so you stop tab-hopping the moment an incident starts.",
    stat: { value: "1", label: "collector / host" },
    points: [
      {
        title: "Unified timeline",
        body: "See a latency spike and the deploy that caused it on one axis.",
      },
      {
        title: "One collector",
        body: "A single lightweight agent per host, defined in one YAML file.",
      },
      {
        title: "Plain-English queries",
        body: "Ask “p99 checkout latency, last hour” and get a chart back.",
      },
    ],
  },
  {
    id: "deploys",
    label: "Deploys",
    heading: "Ship, watch, roll back in seconds",
    blurb:
      "Every release is annotated on your graphs automatically, so a regression is obvious the instant it lands in production.",
    stat: { value: "< 5s", label: "median rollback" },
    points: [
      {
        title: "Deploy markers",
        body: "Each release is pinned to your graphs with commit and author.",
      },
      {
        title: "Guarded rollouts",
        body: "Halt a bad deploy the moment error rate crosses your budget.",
      },
      {
        title: "Change diffs",
        body: "Inspect exactly which services and configs moved in a release.",
      },
    ],
  },
  {
    id: "metrics",
    label: "Metrics",
    heading: "Metrics that don't cost a mortgage",
    blurb:
      "High-cardinality metrics at one-second resolution with predictable per-seat pricing — no surprise overage invoices at month end.",
    stat: { value: "1s", label: "metric resolution" },
    points: [
      {
        title: "High cardinality",
        body: "Tag by user, region, or build without sampling data away.",
      },
      {
        title: "Per-seat pricing",
        body: "A predictable monthly cost, never a metered usage bill.",
      },
      {
        title: "90-day retention",
        body: "Query a full quarter of raw metrics at full resolution.",
      },
    ],
  },
  {
    id: "alerts",
    label: "Alerts",
    heading: "Alerts your team won't mute",
    blurb:
      "Symptom-based alerting with built-in deduplication routes the right page to the right on-call, instead of paging the whole channel.",
    stat: { value: "92%", label: "less alert noise" },
    points: [
      {
        title: "Symptom-based",
        body: "Page on what users feel, not on every transient CPU blip.",
      },
      {
        title: "Smart dedupe",
        body: "Collapse a hundred related failures into one clear incident.",
      },
      {
        title: "Routed on-call",
        body: "The right team gets paged; the shared channel stays quiet.",
      },
    ],
  },
  {
    id: "access",
    label: "Access",
    heading: "Access control without the ticket queue",
    blurb:
      "SSO, SCIM, and scoped API tokens mean granting a contractor read-only access takes a single click, not a two-week sprint.",
    stat: { value: "SCIM 2.0", label: "auto-provisioning" },
    points: [
      {
        title: "SSO + SCIM",
        body: "Provision and deprovision from Okta or Entra automatically.",
      },
      {
        title: "Scoped tokens",
        body: "Grant read-only API keys that expire on a fixed schedule.",
      },
      {
        title: "Audit trail",
        body: "Every query and config change is logged and exportable.",
      },
    ],
  },
];

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      className="h-3.5 w-3.5"
    >
      <path
        d="M4 10.5 8 14.5 16 6"
        stroke="currentColor"
        strokeWidth={2.25}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function TabUnderline() {
  const uid = useId();
  const reduce = useReducedMotion();

  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<Array<HTMLButtonElement | null>>([]);

  const measure = useCallback(() => {
    const el = tabRefs.current[active];
    if (el) setIndicator({ left: el.offsetLeft, width: el.offsetWidth });
  }, [active]);

  useIsomorphicLayoutEffect(() => {
    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]);

  const onKeyDown = (
    e: React.KeyboardEvent<HTMLButtonElement>,
    i: number,
  ) => {
    const count = TABS.length;
    let next = i;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        next = (i + 1) % count;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        next = (i - 1 + count) % count;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = count - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    setActive(next);
    tabRefs.current[next]?.focus();
  };

  const activeTab = TABS[active];
  const panelId = `${uid}-panel`;

  return (
    <section className="relative w-full overflow-hidden bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes tabund-ping {
          75%, 100% { transform: scale(2.4); opacity: 0; }
        }
        .tabund-ping { animation: tabund-ping 1.9s cubic-bezier(0, 0, 0.2, 1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tabund-ping { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-10">
          <p className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Product tour
          </p>
          <h2 className="text-balance text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Run production with fewer tabs open
          </h2>
        </header>

        {/* Tablist */}
        <div
          ref={listRef}
          role="tablist"
          aria-label="Beacon platform features"
          aria-orientation="horizontal"
          className="relative flex gap-1 overflow-x-auto border-b border-slate-200 pb-px [scrollbar-width:none] dark:border-slate-800"
        >
          {TABS.map((tab, i) => {
            const selected = i === active;
            return (
              <button
                key={tab.id}
                ref={(el) => {
                  tabRefs.current[i] = el;
                }}
                id={`${uid}-tab-${i}`}
                role="tab"
                type="button"
                aria-selected={selected}
                aria-controls={panelId}
                tabIndex={selected ? 0 : -1}
                onClick={() => setActive(i)}
                onKeyDown={(e) => onKeyDown(e, i)}
                className={`relative shrink-0 rounded-t-md px-4 py-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 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>
            );
          })}

          <motion.span
            aria-hidden="true"
            className="absolute -bottom-px left-0 h-0.5 rounded-full bg-indigo-500 dark:bg-indigo-400"
            initial={false}
            animate={{ x: indicator.left, width: indicator.width }}
            transition={
              reduce
                ? { duration: 0 }
                : { type: "spring", stiffness: 520, damping: 42, mass: 0.9 }
            }
          />
        </div>

        {/* Panel */}
        <motion.div
          key={active}
          role="tabpanel"
          id={panelId}
          aria-labelledby={`${uid}-tab-${active}`}
          tabIndex={0}
          initial={reduce ? false : { opacity: 0, y: 12 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{
            duration: reduce ? 0 : 0.32,
            ease: [0.22, 1, 0.36, 1],
          }}
          className="mt-8 rounded-2xl border border-slate-200 bg-slate-50/60 p-6 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-800 dark:bg-slate-900/40 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950 sm:p-8"
        >
          <div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
            <div className="max-w-xl">
              <div className="mb-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300">
                <span className="relative flex h-2 w-2">
                  <span className="tabund-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400/70" />
                  <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
                </span>
                Live data
              </div>
              <h3 className="text-xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-2xl">
                {activeTab.heading}
              </h3>
              <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                {activeTab.blurb}
              </p>
            </div>

            <div className="shrink-0 rounded-xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-950/50">
              <div className="text-2xl font-semibold tracking-tight text-indigo-600 tabular-nums dark:text-indigo-400">
                {activeTab.stat.value}
              </div>
              <div className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                {activeTab.stat.label}
              </div>
            </div>
          </div>

          <ul className="mt-6 grid gap-3 sm:grid-cols-3">
            {activeTab.points.map((p) => (
              <li
                key={p.title}
                className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950/40"
              >
                <div className="mb-2 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
                  <CheckIcon />
                </div>
                <div className="text-sm font-semibold text-slate-900 dark:text-white">
                  {p.title}
                </div>
                <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                  {p.body}
                </p>
              </li>
            ))}
          </ul>
        </motion.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 →