Web InnoventixFreeCode

Vertical Tab

Original · free

vertical tabs

byWeb InnoventixReact + Tailwind
tabverticaltabs
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-vertical.json
tab-vertical.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
  eyebrow: string
  label: string
  summary: string
  heading: string
  description: string
  features: string[]
  stats: Stat[]
  cta: string
  icon: ReactNode
}

const iconClass = "h-5 w-5"

const TABS: Tab[] = [
  {
    id: "pipelines",
    eyebrow: "Continuous delivery",
    label: "Pipelines",
    summary: "Build, preview, and roll back",
    heading: "Ship on every merge, roll back in one click",
    description:
      "Every pull request gets its own preview URL and a full production-grade build. Promote to production with a progressive rollout, and if error rates climb past your budget, Meridian returns to the last healthy release on its own.",
    features: [
      "Isolated preview environment for every pull request",
      "Progressive canary rollouts with automatic error-budget checks",
      "One-click rollback that reuses the last known-good build",
    ],
    stats: [
      { value: "9s", label: "Median push to preview" },
      { value: "48/day", label: "Deploys per team" },
      { value: "1-click", label: "Full rollback" },
    ],
    cta: "Explore pipelines",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.7}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={iconClass}
        aria-hidden="true"
      >
        <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 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
        <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
        <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
      </svg>
    ),
  },
  {
    id: "insights",
    eyebrow: "Observability",
    label: "Insights",
    summary: "Traces, logs, and metrics",
    heading: "Trace any request from the edge to the database",
    description:
      "Correlated traces, logs, and metrics live behind a single query bar. Click a slow request and jump straight to the exact function call, database query, or upstream service that held it up.",
    features: [
      "Distributed tracing stitched across every service hop",
      "Logs and metrics linked to the trace that produced them",
      "Saved views and alerts that page the right on-call engineer",
    ],
    stats: [
      { value: "14mo", label: "Trace retention" },
      { value: "<200ms", label: "Query response" },
      { value: "100%", label: "Sampled requests" },
    ],
    cta: "Explore insights",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.7}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={iconClass}
        aria-hidden="true"
      >
        <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
      </svg>
    ),
  },
  {
    id: "incidents",
    eyebrow: "Incident response",
    label: "Incidents",
    summary: "Detect, page, resolve",
    heading: "Resolve incidents before customers file a ticket",
    description:
      "Meridian opens an incident the moment an error budget breaks, spins up a dedicated channel, and attaches the traces and deploys that changed right before things went sideways — so responders start with context, not questions.",
    features: [
      "Automatic incident creation from error-budget breaches",
      "Timeline that pins the deploy and change that triggered it",
      "Post-incident reviews generated from the real timeline",
    ],
    stats: [
      { value: "90s", label: "Detect to page" },
      { value: "3x", label: "Faster resolution" },
      { value: "0", label: "Manual status pages" },
    ],
    cta: "Explore incidents",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.7}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={iconClass}
        aria-hidden="true"
      >
        <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
        <line x1="12" y1="9" x2="12" y2="13" />
        <line x1="12" y1="17" x2="12.01" y2="17" />
      </svg>
    ),
  },
  {
    id: "access",
    eyebrow: "Governance",
    label: "Access",
    summary: "Least-privilege by default",
    heading: "Least-privilege access without the ticket queue",
    description:
      "Engineers request scoped, time-boxed access to exactly what they need, approvals happen in chat, and every grant expires on its own. Auditors get an immutable log without asking a single person for it.",
    features: [
      "Time-boxed, scoped grants that expire automatically",
      "Chat-based approvals with a full audit trail",
      "SCIM provisioning and SSO for every identity provider",
    ],
    stats: [
      { value: "15min", label: "Default grant window" },
      { value: "SOC 2", label: "Type II audited" },
      { value: "100%", label: "Grants logged" },
    ],
    cta: "Explore access",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.7}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={iconClass}
        aria-hidden="true"
      >
        <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
        <path d="M9 12l2 2 4-4" />
      </svg>
    ),
  },
]

const CheckIcon = () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2.4}
    strokeLinecap="round"
    strokeLinejoin="round"
    className="h-3.5 w-3.5"
    aria-hidden="true"
  >
    <path d="M20 6 9 17l-5-5" />
  </svg>
)

const ArrowIcon = () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    className="h-4 w-4"
    aria-hidden="true"
  >
    <path d="M5 12h13M13 6l6 6-6 6" />
  </svg>
)

export default function TabVertical() {
  const uid = useId()
  const reduce = useReducedMotion()
  const [active, setActive] = useState(0)
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([])

  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    const count = TABS.length
    let next = active
    switch (e.key) {
      case "ArrowDown":
      case "ArrowRight":
        next = (active + 1) % count
        break
      case "ArrowUp":
      case "ArrowLeft":
        next = (active - 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 current = TABS[active]

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 py-20 sm:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes tv-ping {
          0% { transform: scale(1); opacity: 0.55; }
          75%, 100% { transform: scale(2.4); opacity: 0; }
        }
        @keyframes tv-sheen {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        .tv-ping { animation: tv-ping 1.9s cubic-bezier(0, 0, 0.2, 1) infinite; }
        .tv-sheen { background-size: 200% 100%; animation: tv-sheen 4s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tv-ping, .tv-sheen { animation: none !important; }
        }
      `}</style>

      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className="absolute left-1/2 top-[-6rem] h-72 w-[46rem] -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-500/10" />
        <div className="absolute bottom-[-8rem] right-[-4rem] h-72 w-72 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-500/10" />
      </div>

      <div className="relative mx-auto w-full max-w-6xl px-6">
        <div className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            Platform tour
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            One console for everything after{" "}
            <span className="whitespace-nowrap font-mono text-indigo-600 dark:text-indigo-400">
              git&nbsp;push
            </span>
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-300">
            Meridian gives platform teams a single place to deploy, observe,
            respond, and govern — no more stitching together five dashboards and
            hoping they agree with each other.
          </p>
        </div>

        <div className="mt-12 grid gap-4 lg:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] lg:gap-6">
          <div
            role="tablist"
            aria-orientation="vertical"
            aria-label="Platform capabilities"
            className="flex flex-col gap-2"
          >
            {TABS.map((tab, i) => {
              const isActive = active === i
              return (
                <button
                  key={tab.id}
                  ref={(el) => {
                    tabRefs.current[i] = el
                  }}
                  type="button"
                  role="tab"
                  id={`${uid}-tab-${i}`}
                  aria-selected={isActive}
                  aria-controls={`${uid}-panel`}
                  tabIndex={isActive ? 0 : -1}
                  onClick={() => setActive(i)}
                  onKeyDown={onKeyDown}
                  className={[
                    "group relative flex items-start gap-4 rounded-2xl border px-4 py-4 text-left transition-colors",
                    "focus-visible:outline-none 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",
                    isActive
                      ? "border-indigo-200 bg-white shadow-sm shadow-indigo-500/5 dark:border-indigo-500/30 dark:bg-slate-900"
                      : "border-transparent hover:border-slate-200 hover:bg-white/70 dark:hover:border-slate-800 dark:hover:bg-slate-900/50",
                  ].join(" ")}
                >
                  {isActive && (
                    <motion.span
                      layoutId={`${uid}-bar`}
                      aria-hidden="true"
                      className="absolute bottom-3 left-0 top-3 w-1 rounded-full bg-gradient-to-b from-indigo-500 to-violet-500"
                      transition={
                        reduce
                          ? { duration: 0 }
                          : { type: "spring", stiffness: 520, damping: 42 }
                      }
                    />
                  )}
                  <span
                    className={[
                      "flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border transition-colors",
                      isActive
                        ? "border-transparent bg-gradient-to-br from-indigo-500 to-violet-500 text-white"
                        : "border-slate-200 bg-slate-100 text-slate-500 group-hover:text-slate-700 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400 dark:group-hover:text-slate-200",
                    ].join(" ")}
                  >
                    {tab.icon}
                  </span>
                  <span className="min-w-0">
                    <span
                      className={[
                        "block text-sm font-semibold transition-colors",
                        isActive
                          ? "text-slate-900 dark:text-white"
                          : "text-slate-700 dark:text-slate-200",
                      ].join(" ")}
                    >
                      {tab.label}
                    </span>
                    <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
                      {tab.summary}
                    </span>
                  </span>
                </button>
              )
            })}
          </div>

          <div
            role="tabpanel"
            id={`${uid}-panel`}
            aria-labelledby={`${uid}-tab-${active}`}
            tabIndex={0}
            className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white p-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 sm:p-8 dark:border-slate-800 dark:bg-slate-900"
          >
            <span
              aria-hidden="true"
              className="tv-sheen absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500 to-transparent"
            />

            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={active}
                initial={reduce ? false : { opacity: 0, y: 10 }}
                animate={reduce ? {} : { opacity: 1, y: 0 }}
                exit={reduce ? {} : { opacity: 0, y: -8 }}
                transition={{ duration: 0.26, ease: "easeOut" }}
              >
                <div className="flex items-center justify-between gap-4">
                  <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                    <span className="relative flex h-2 w-2">
                      <span className="tv-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400" />
                      <span className="relative inline-flex h-2 w-2 rounded-full bg-indigo-500" />
                    </span>
                    {current.eyebrow}
                  </span>
                  <span className="font-mono text-xs tabular-nums text-slate-400 dark:text-slate-500">
                    {String(active + 1).padStart(2, "0")} /{" "}
                    {String(TABS.length).padStart(2, "0")}
                  </span>
                </div>

                <h3 className="mt-5 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-white">
                  {current.heading}
                </h3>
                <p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-300">
                  {current.description}
                </p>

                <ul className="mt-6 space-y-3">
                  {current.features.map((feature) => (
                    <li key={feature} className="flex items-start gap-3">
                      <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">
                        <CheckIcon />
                      </span>
                      <span className="text-sm text-slate-600 dark:text-slate-300">
                        {feature}
                      </span>
                    </li>
                  ))}
                </ul>

                <dl className="mt-8 grid grid-cols-3 gap-4 border-t border-slate-200 pt-6 dark:border-slate-800">
                  {current.stats.map((stat) => (
                    <div key={stat.label}>
                      <dt className="sr-only">{stat.label}</dt>
                      <dd className="text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-white">
                        {stat.value}
                      </dd>
                      <p className="mt-1 text-xs leading-snug text-slate-500 dark:text-slate-400">
                        {stat.label}
                      </p>
                    </div>
                  ))}
                </dl>

                <button
                  type="button"
                  className="mt-8 inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
                >
                  {current.cta}
                  <ArrowIcon />
                </button>
              </motion.div>
            </AnimatePresence>
          </div>
        </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 →