Web InnoventixFreeCode

Animated Indicator Tab

Original · free

tabs with a morphing indicator

byWeb InnoventixReact + Tailwind
tabanimatedindicatortabs
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-animated-indicator.json
tab-animated-indicator.tsx
"use client"

import {
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react"
import { motion, useReducedMotion } from "motion/react"

type Stat = { value: string; label: string }

type TabItem = {
  id: string
  label: string
  icon: ReactNode
  kicker: string
  heading: string
  body: string
  points: string[]
  stats: Stat[]
  live?: boolean
}

function StreamIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="M3 12h3l2 5 4-14 2 9h4" />
    </svg>
  )
}

function CohortIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <circle cx="9" cy="8" r="3" />
      <path d="M15.5 6.2a3 3 0 0 1 0 5.6" />
      <path d="M3.5 19a5.5 5.5 0 0 1 11 0" />
      <path d="M17 13.7A5.5 5.5 0 0 1 20.5 19" />
    </svg>
  )
}

function FunnelIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="M3 5h18l-7 8v6l-4 2v-8L3 5z" />
    </svg>
  )
}

function AlertIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" />
      <path d="M10 20a2 2 0 0 0 4 0" />
    </svg>
  )
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500 dark:text-emerald-400"
      aria-hidden="true"
    >
      <path d="M20 6 9 17l-5-5" />
    </svg>
  )
}

const TABS: TabItem[] = [
  {
    id: "stream",
    label: "Live stream",
    icon: <StreamIcon />,
    kicker: "Realtime pipeline",
    heading: "Watch events land the moment they fire",
    body: "Every click, purchase, and API call streams into your workspace with sub-second latency. Filter the firehose by user, property, or source without ever waiting on a batch job to finish.",
    points: [
      "Sub-second ingestion from web, mobile, and server SDKs",
      "Live filters that narrow the stream as you type",
      "Replay any session to see the exact order of events",
    ],
    stats: [
      { value: "420ms", label: "median end-to-end latency" },
      { value: "38B", label: "events processed each week" },
    ],
    live: true,
  },
  {
    id: "cohorts",
    label: "Cohorts",
    icon: <CohortIcon />,
    kicker: "Retention",
    heading: "Group users by what they did, not who they are",
    body: "Build cohorts from any sequence of behaviors and track how each group retains week over week. Compare a feature's adopters against everyone else in two clicks, no SQL required.",
    points: [
      "Behavioral cohorts from any event sequence",
      "Week, day, or month retention grids side by side",
      "Compare any cohort against a control group",
    ],
    stats: [
      { value: "+17%", label: "week-4 retention for activated users" },
      { value: "52", label: "saved cohort definitions" },
    ],
  },
  {
    id: "funnels",
    label: "Funnels",
    icon: <FunnelIcon />,
    kicker: "Conversion",
    heading: "Find the exact step where people drop",
    body: "Chart a multi-step funnel and see conversion and drop-off between every stage. Break any step down by device, plan, or campaign to find where the leak actually is.",
    points: [
      "Unlimited steps with configurable conversion windows",
      "Drop-off broken down by any user property",
      "Automatic highlight of the single biggest leak",
    ],
    stats: [
      { value: "6.4%", label: "signup to paid conversion" },
      { value: "Step 3", label: "where most users exit today" },
    ],
  },
  {
    id: "alerts",
    label: "Alerts",
    icon: <AlertIcon />,
    kicker: "Monitoring",
    heading: "Get pinged before a metric becomes a fire",
    body: "Set fixed thresholds or let anomaly detection learn what normal looks like for each metric. Route alerts to Slack, email, or a webhook, and mute the noise with smart grouping.",
    points: [
      "Static thresholds and learned anomaly baselines",
      "Delivery to Slack, email, PagerDuty, or webhook",
      "Grouping that collapses duplicate alerts into one",
    ],
    stats: [
      { value: "90s", label: "median time to first alert" },
      { value: "0", label: "noisy duplicate pages last month" },
    ],
  },
]

export default function TabAnimatedIndicator() {
  const uid = useId()
  const prefersReduced = useReducedMotion()
  const [active, setActive] = useState<string>(TABS[0].id)
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([])

  const tabDomId = (id: string) => `${uid}-tab-${id}`
  const panelDomId = (id: string) => `${uid}-panel-${id}`

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const count = TABS.length
    const currentIndex = TABS.findIndex((tab) => tab.id === active)
    let nextIndex = currentIndex

    switch (event.key) {
      case "ArrowRight":
      case "ArrowDown":
        nextIndex = (currentIndex + 1) % count
        break
      case "ArrowLeft":
      case "ArrowUp":
        nextIndex = (currentIndex - 1 + count) % count
        break
      case "Home":
        nextIndex = 0
        break
      case "End":
        nextIndex = count - 1
        break
      default:
        return
    }

    event.preventDefault()
    const nextTab = TABS[nextIndex]
    setActive(nextTab.id)
    tabRefs.current[nextIndex]?.focus()
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tai-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.4; transform: scale(0.68); }
        }
        @keyframes tai-ring {
          0% { opacity: 0.6; transform: scale(1); }
          100% { opacity: 0; transform: scale(2.6); }
        }
        @keyframes tai-drift {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(7%, -9%, 0); }
        }
        .tai-livedot { animation: tai-pulse 1.8s ease-in-out infinite; }
        .tai-livering { animation: tai-ring 1.8s ease-out infinite; }
        .tai-blob { animation: tai-drift 16s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tai-livedot, .tai-livering, .tai-blob { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="tai-blob pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-300/40 via-violet-300/30 to-sky-300/30 blur-3xl dark:from-indigo-600/25 dark:via-violet-600/20 dark:to-sky-600/20"
      />

      <div className="relative mx-auto max-w-5xl">
        <div className="mx-auto max-w-2xl 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-semibold uppercase tracking-wide text-indigo-600 dark:border-slate-800 dark:bg-slate-900 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
            Product analytics
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            One workspace for every product question
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-slate-400">
            Switch between the tools your team reaches for daily. The indicator
            morphs to wherever you land.
          </p>
        </div>

        <div className="mt-10 flex justify-center">
          <div className="max-w-full overflow-x-auto pb-1">
            <div
              role="tablist"
              aria-label="Product analytics capabilities"
              aria-orientation="horizontal"
              onKeyDown={onKeyDown}
              className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
            >
              {TABS.map((tab, index) => {
                const isActive = tab.id === active
                return (
                  <button
                    key={tab.id}
                    ref={(el) => {
                      tabRefs.current[index] = el
                    }}
                    type="button"
                    role="tab"
                    id={tabDomId(tab.id)}
                    aria-selected={isActive}
                    aria-controls={panelDomId(tab.id)}
                    tabIndex={isActive ? 0 : -1}
                    onClick={() => setActive(tab.id)}
                    className={`relative flex items-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                      isActive
                        ? "text-slate-900 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {isActive && (
                      <motion.span
                        layoutId={`${uid}-pill`}
                        className="absolute inset-0 rounded-full bg-slate-100 ring-1 ring-slate-900/5 dark:bg-slate-800 dark:ring-white/10"
                        transition={
                          prefersReduced
                            ? { duration: 0 }
                            : { type: "spring", stiffness: 380, damping: 32 }
                        }
                      />
                    )}
                    <span
                      className={`relative z-10 ${
                        isActive
                          ? "text-indigo-600 dark:text-indigo-300"
                          : ""
                      }`}
                    >
                      {tab.icon}
                    </span>
                    <span className="relative z-10">{tab.label}</span>
                  </button>
                )
              })}
            </div>
          </div>
        </div>

        <div className="mt-8">
          {TABS.map((tab) => {
            const isActive = tab.id === active
            return (
              <div
                key={tab.id}
                role="tabpanel"
                id={panelDomId(tab.id)}
                aria-labelledby={tabDomId(tab.id)}
                hidden={!isActive}
                tabIndex={0}
                className="rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur focus:outline-none focus-visible: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/70 dark:focus-visible:ring-offset-slate-950"
              >
                {isActive && (
                  <motion.div
                    initial={prefersReduced ? false : { opacity: 0, y: 10 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{
                      duration: prefersReduced ? 0 : 0.28,
                      ease: [0.22, 1, 0.36, 1],
                    }}
                    className="grid gap-8 lg:grid-cols-5"
                  >
                    <div className="lg:col-span-3">
                      <div className="flex items-center gap-3">
                        <span className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-300">
                          {tab.kicker}
                        </span>
                        {tab.live && (
                          <span className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
                            <span className="relative inline-flex h-2.5 w-2.5">
                              <span className="tai-livering absolute inline-flex h-full w-full rounded-full bg-emerald-400/70" />
                              <span className="tai-livedot relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
                            </span>
                            Live now
                          </span>
                        )}
                      </div>

                      <h3 className="mt-3 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
                        {tab.heading}
                      </h3>
                      <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
                        {tab.body}
                      </p>

                      <ul role="list" className="mt-6 space-y-3">
                        {tab.points.map((point) => (
                          <li
                            key={point}
                            className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300"
                          >
                            <CheckIcon />
                            <span>{point}</span>
                          </li>
                        ))}
                      </ul>
                    </div>

                    <div className="flex flex-col gap-4 lg:col-span-2">
                      {tab.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/50"
                        >
                          <div className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-white">
                            {stat.value}
                          </div>
                          <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                            {stat.label}
                          </div>
                        </div>
                      ))}
                    </div>
                  </motion.div>
                )}
              </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 →