Web InnoventixFreeCode

Segmented Tab

Original · free

segmented control tabs

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

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

type IconProps = SVGProps<SVGSVGElement>;

function IconOverview(props: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
      <rect x="3" y="3" width="7" height="9" rx="1.5" />
      <rect x="14" y="3" width="7" height="5" rx="1.5" />
      <rect x="14" y="12" width="7" height="9" rx="1.5" />
      <rect x="3" y="16" width="7" height="5" rx="1.5" />
    </svg>
  );
}

function IconAnalytics(props: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
      <path d="M4 20V4" />
      <path d="M4 20h16" />
      <path d="M8 16l3.5-4 3 2.5L20 8" />
    </svg>
  );
}

function IconMembers(props: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
      <circle cx="9" cy="8" r="3.2" />
      <path d="M3.5 19a5.5 5.5 0 0 1 11 0" />
      <path d="M16 5.2a3.2 3.2 0 0 1 0 5.6" />
      <path d="M17 14.2A5.5 5.5 0 0 1 20.5 19" />
    </svg>
  );
}

function IconSettings(props: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
      <circle cx="12" cy="12" r="3" />
      <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" />
    </svg>
  );
}

function IconArrow(props: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
      <path d="M7 17L17 7" />
      <path d="M9 7h8v8" />
    </svg>
  );
}

type TabId = "overview" | "analytics" | "members" | "settings";

type Tab = {
  id: TabId;
  label: string;
  icon: ComponentType<IconProps>;
};

const TABS: Tab[] = [
  { id: "overview", label: "Overview", icon: IconOverview },
  { id: "analytics", label: "Analytics", icon: IconAnalytics },
  { id: "members", label: "Members", icon: IconMembers },
  { id: "settings", label: "Settings", icon: IconSettings },
];

type Stat = {
  label: string;
  value: string;
  delta: string;
  trend: "up" | "down";
};

const STATS: Stat[] = [
  { label: "Active users", value: "12,480", delta: "+8.2%", trend: "up" },
  { label: "Avg. session", value: "4m 12s", delta: "+0.6%", trend: "up" },
  { label: "Conversion", value: "3.9%", delta: "-0.4%", trend: "down" },
  { label: "Net MRR", value: "$48.2k", delta: "+12.1%", trend: "up" },
];

type Bar = { day: string; value: number };

const SIGNUPS: Bar[] = [
  { day: "Mon", value: 42 },
  { day: "Tue", value: 58 },
  { day: "Wed", value: 51 },
  { day: "Thu", value: 74 },
  { day: "Fri", value: 66 },
  { day: "Sat", value: 39 },
  { day: "Sun", value: 47 },
];

const SOURCES: { name: string; pct: number }[] = [
  { name: "Organic search", pct: 41 },
  { name: "Direct", pct: 27 },
  { name: "Referral", pct: 18 },
  { name: "Social", pct: 14 },
];

type Member = { name: string; role: string; email: string; tone: string };

const MEMBERS: Member[] = [
  { name: "Aisha Rahman", role: "Owner", email: "aisha@meridian.io", tone: "bg-indigo-500" },
  { name: "Marco Silva", role: "Admin", email: "marco@meridian.io", tone: "bg-emerald-500" },
  { name: "Lena Fischer", role: "Editor", email: "lena@meridian.io", tone: "bg-rose-500" },
  { name: "Diego Torres", role: "Viewer", email: "diego@meridian.io", tone: "bg-amber-500" },
  { name: "Priya Nair", role: "Editor", email: "priya@meridian.io", tone: "bg-sky-500" },
];

function initials(name: string): string {
  return name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .slice(0, 2)
    .toUpperCase();
}

function Panel({ children }: { children: ReactNode }) {
  return <div className="grid gap-4">{children}</div>;
}

function OverviewPanel() {
  return (
    <Panel>
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
        {STATS.map((stat) => (
          <div
            key={stat.label}
            className="rounded-2xl border border-zinc-200 bg-white/70 p-4 dark:border-zinc-800 dark:bg-zinc-900/60"
          >
            <p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">{stat.label}</p>
            <p className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">
              {stat.value}
            </p>
            <span
              className={
                stat.trend === "up"
                  ? "mt-1 inline-flex items-center gap-1 text-xs font-medium text-emerald-600 dark:text-emerald-400"
                  : "mt-1 inline-flex items-center gap-1 text-xs font-medium text-rose-600 dark:text-rose-400"
              }
            >
              <svg
                viewBox="0 0 12 12"
                className={stat.trend === "down" ? "h-3 w-3 rotate-180" : "h-3 w-3"}
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M6 9V3M3 6l3-3 3 3" />
              </svg>
              {stat.delta}
            </span>
          </div>
        ))}
      </div>
      <div className="rounded-2xl border border-zinc-200 bg-white/70 p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
        <h3 className="text-sm font-semibold text-zinc-900 dark:text-white">This week at Meridian</h3>
        <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
          Retention held steady at 91% while onboarding completion climbed four points after the new
          welcome checklist shipped Tuesday. Support volume dropped 12% week over week.
        </p>
      </div>
    </Panel>
  );
}

function AnalyticsPanel() {
  const max = Math.max(...SIGNUPS.map((bar) => bar.value));
  return (
    <Panel>
      <div className="rounded-2xl border border-zinc-200 bg-white/70 p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
        <div className="flex items-center justify-between">
          <h3 className="text-sm font-semibold text-zinc-900 dark:text-white">Signups this week</h3>
          <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/25">
            <span className="seg-anim-pulse h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Live
          </span>
        </div>
        <div className="mt-5 flex h-40 items-end justify-between gap-2">
          {SIGNUPS.map((bar, index) => (
            <div key={bar.day} className="flex h-full flex-1 flex-col items-center justify-end gap-2">
              <div
                className="seg-anim-rise w-full origin-bottom rounded-t-md bg-gradient-to-t from-indigo-500 to-violet-400 dark:from-indigo-500 dark:to-violet-400"
                style={{ height: `${(bar.value / max) * 100}%`, animationDelay: `${index * 55}ms` }}
              />
              <span className="text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{bar.day}</span>
            </div>
          ))}
        </div>
      </div>
      <div className="rounded-2xl border border-zinc-200 bg-white/70 p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
        <h3 className="text-sm font-semibold text-zinc-900 dark:text-white">Top acquisition sources</h3>
        <ul className="mt-4 space-y-3">
          {SOURCES.map((source) => (
            <li key={source.name} className="flex items-center gap-3">
              <span className="w-28 shrink-0 text-sm text-zinc-600 dark:text-zinc-400">{source.name}</span>
              <span className="h-2 flex-1 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
                <span
                  className="block h-full rounded-full bg-indigo-500"
                  style={{ width: `${source.pct}%` }}
                />
              </span>
              <span className="w-10 shrink-0 text-right text-sm font-medium tabular-nums text-zinc-900 dark:text-white">
                {source.pct}%
              </span>
            </li>
          ))}
        </ul>
      </div>
    </Panel>
  );
}

function MembersPanel() {
  return (
    <Panel>
      <ul className="divide-y divide-zinc-200 overflow-hidden rounded-2xl border border-zinc-200 bg-white/70 dark:divide-zinc-800 dark:border-zinc-800 dark:bg-zinc-900/60">
        {MEMBERS.map((member) => (
          <li key={member.email} className="flex items-center gap-3 p-4">
            <span
              className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white ${member.tone}`}
              aria-hidden="true"
            >
              {initials(member.name)}
            </span>
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-medium text-zinc-900 dark:text-white">{member.name}</p>
              <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
            </div>
            <span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
              {member.role}
            </span>
          </li>
        ))}
      </ul>
    </Panel>
  );
}

function Toggle({
  label,
  description,
  checked,
  onChange,
}: {
  label: string;
  description: string;
  checked: boolean;
  onChange: (next: boolean) => void;
}) {
  return (
    <div className="flex items-center justify-between gap-4 rounded-2xl border border-zinc-200 bg-white/70 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
      <div className="min-w-0">
        <p className="text-sm font-medium text-zinc-900 dark:text-white">{label}</p>
        <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">{description}</p>
      </div>
      <button
        type="button"
        role="switch"
        aria-checked={checked}
        aria-label={label}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors 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-zinc-950 ${
          checked ? "bg-indigo-500" : "bg-zinc-300 dark:bg-zinc-700"
        }`}
      >
        <span
          className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
            checked ? "translate-x-5" : "translate-x-0.5"
          }`}
        />
      </button>
    </div>
  );
}

function SettingsPanel() {
  const [digest, setDigest] = useState(true);
  const [twoFactor, setTwoFactor] = useState(false);
  return (
    <Panel>
      <div className="rounded-2xl border border-zinc-200 bg-white/70 p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
        <h3 className="text-sm font-semibold text-zinc-900 dark:text-white">Workspace</h3>
        <dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
          <div className="flex justify-between gap-4 sm:block">
            <dt className="text-zinc-500 dark:text-zinc-400">Name</dt>
            <dd className="font-medium text-zinc-900 dark:text-white">Meridian</dd>
          </div>
          <div className="flex justify-between gap-4 sm:block">
            <dt className="text-zinc-500 dark:text-zinc-400">Timezone</dt>
            <dd className="font-medium text-zinc-900 dark:text-white">UTC+05:00 · Karachi</dd>
          </div>
        </dl>
      </div>
      <Toggle
        label="Weekly digest email"
        description="A Monday summary of metrics and activity sent to admins."
        checked={digest}
        onChange={setDigest}
      />
      <Toggle
        label="Two-factor authentication"
        description="Require a second step at sign-in for everyone in the workspace."
        checked={twoFactor}
        onChange={setTwoFactor}
      />
    </Panel>
  );
}

const PANELS: Record<TabId, ComponentType> = {
  overview: OverviewPanel,
  analytics: AnalyticsPanel,
  members: MembersPanel,
  settings: SettingsPanel,
};

export default function TabSegmented() {
  const prefersReduced = useReducedMotion();
  const uid = useId();
  const [active, setActive] = useState<TabId>("overview");
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const activeIndex = TABS.findIndex((tab) => tab.id === active);

  function focusTab(index: number) {
    const clamped = (index + TABS.length) % TABS.length;
    setActive(TABS[clamped].id);
    tabRefs.current[clamped]?.focus();
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    switch (event.key) {
      case "ArrowRight":
      case "ArrowDown":
        event.preventDefault();
        focusTab(activeIndex + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        event.preventDefault();
        focusTab(activeIndex - 1);
        break;
      case "Home":
        event.preventDefault();
        focusTab(0);
        break;
      case "End":
        event.preventDefault();
        focusTab(TABS.length - 1);
        break;
      default:
        break;
    }
  }

  const ActivePanel = PANELS[active];

  return (
    <section className="relative w-full bg-zinc-50 px-4 py-16 text-zinc-900 sm:px-6 sm:py-24 dark:bg-zinc-950 dark:text-zinc-100">
      <style>{`
        @keyframes seg-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(0.6); }
        }
        @keyframes seg-rise {
          from { transform: scaleY(0); }
          to { transform: scaleY(1); }
        }
        .seg-anim-pulse { animation: seg-pulse 1.8s ease-in-out infinite; }
        .seg-anim-rise { animation: seg-rise 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .seg-anim-pulse, .seg-anim-rise { animation: none !important; }
          .seg-anim-rise { transform: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-8 text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Meridian workspace
          </p>
          <h2 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl dark:text-white">
            One dashboard, four lenses
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-zinc-600 dark:text-zinc-400">
            Switch views with the segmented control below. Arrow keys, Home and End move between
            tabs; the panel updates instantly.
          </p>
        </div>

        <div
          role="tablist"
          aria-label="Dashboard sections"
          aria-orientation="horizontal"
          onKeyDown={onKeyDown}
          className="mx-auto flex w-full max-w-md gap-1 rounded-full border border-zinc-200 bg-zinc-100 p-1 shadow-inner dark:border-zinc-800 dark:bg-zinc-900"
        >
          {TABS.map((tab, index) => {
            const selected = tab.id === active;
            const Icon = tab.icon;
            const thumb = prefersReduced ? (
              <span className="absolute inset-0 rounded-full bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700" />
            ) : (
              <motion.span
                layoutId={`${uid}-thumb`}
                transition={{ type: "spring", stiffness: 420, damping: 34 }}
                className="absolute inset-0 rounded-full bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700"
              />
            );
            return (
              <button
                key={tab.id}
                ref={(node) => {
                  tabRefs.current[index] = node;
                }}
                role="tab"
                type="button"
                id={`${uid}-tab-${tab.id}`}
                aria-selected={selected}
                aria-controls={`${uid}-panel-${tab.id}`}
                tabIndex={selected ? 0 : -1}
                onClick={() => setActive(tab.id)}
                className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-full px-2 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900 ${
                  selected
                    ? "text-zinc-900 dark:text-white"
                    : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
                }`}
              >
                {selected && thumb}
                <span className="relative z-10 flex items-center gap-1.5">
                  <Icon className="h-4 w-4" />
                  <span className="hidden sm:inline">{tab.label}</span>
                  <span className="sr-only sm:hidden">{tab.label}</span>
                </span>
              </button>
            );
          })}
        </div>

        <div className="mt-6 min-h-[22rem]">
          <AnimatePresence mode="wait" initial={false}>
            <motion.div
              key={active}
              role="tabpanel"
              id={`${uid}-panel-${active}`}
              aria-labelledby={`${uid}-tab-${active}`}
              tabIndex={0}
              initial={prefersReduced ? false : { opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={prefersReduced ? { opacity: 1 } : { opacity: 0, y: -8 }}
              transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
              className="rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-950"
            >
              <ActivePanel />
            </motion.div>
          </AnimatePresence>
        </div>

        <p className="mt-6 flex items-center justify-center gap-1.5 text-xs text-zinc-400 dark:text-zinc-500">
          Viewing the
          <span className="font-medium text-zinc-600 dark:text-zinc-300">
            {TABS[activeIndex].label}
          </span>
          panel
          <IconArrow className="h-3 w-3" />
        </p>
      </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 →