Web InnoventixFreeCode

Icons Tab

Original · free

tabs with icons

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

import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactElement } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/* ----------------------------------------------------------------------------
   Icons (inline, stroke = currentColor)
---------------------------------------------------------------------------- */

function GridIcon({ className }: { className?: string }): ReactElement {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3.5" y="3.5" width="7" height="7" rx="2" />
      <rect x="13.5" y="3.5" width="7" height="7" rx="2" />
      <rect x="3.5" y="13.5" width="7" height="7" rx="2" />
      <rect x="13.5" y="13.5" width="7" height="7" rx="2" />
    </svg>
  );
}

function PulseIcon({ className }: { className?: string }): ReactElement {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3 12h3.5l2-6 4 12 2.4-8 1.6 2H21" />
    </svg>
  );
}

function UsersIcon({ className }: { className?: string }): ReactElement {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="9" cy="8" r="3.2" />
      <path d="M3.5 19.5a5.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.5 14.4a5.5 5.5 0 0 1 3 5.1" />
    </svg>
  );
}

function TagIcon({ className }: { className?: string }): ReactElement {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3.5 11.5V4.5a1 1 0 0 1 1-1h7l8 8a1.6 1.6 0 0 1 0 2.3l-5.7 5.7a1.6 1.6 0 0 1-2.3 0l-8-8Z" />
      <circle cx="8" cy="8" r="1.3" />
    </svg>
  );
}

function ArrowUpIcon({ className }: { className?: string }): ReactElement {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 19V5" />
      <path d="m6 11 6-6 6 6" />
    </svg>
  );
}

function CheckIcon({ className }: { className?: string }): ReactElement {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m4.5 12.5 5 5 10-11" />
    </svg>
  );
}

/* ----------------------------------------------------------------------------
   Data
---------------------------------------------------------------------------- */

type TabId = "overview" | "activity" | "team" | "releases";

interface TabDef {
  id: TabId;
  label: string;
  Icon: (props: { className?: string }) => ReactElement;
  count?: number;
}

const TABS: TabDef[] = [
  { id: "overview", label: "Overview", Icon: GridIcon },
  { id: "activity", label: "Activity", Icon: PulseIcon, count: 3 },
  { id: "team", label: "Team", Icon: UsersIcon, count: 4 },
  { id: "releases", label: "Releases", Icon: TagIcon },
];

interface Metric {
  label: string;
  value: string;
  note: string;
  tone: "emerald" | "sky" | "amber";
}

const METRICS: Metric[] = [
  { label: "Active projects", value: "12", note: "+2 this month", tone: "emerald" },
  { label: "Deploys this week", value: "47", note: "94% success rate", tone: "sky" },
  { label: "Open issues", value: "8", note: "3 high priority", tone: "amber" },
];

interface Event {
  who: string;
  what: string;
  when: string;
  tone: "indigo" | "emerald" | "sky" | "amber";
}

const EVENTS: Event[] = [
  { who: "Priya Nair", what: "merged “Reduce cold-start latency” into main", when: "14:20", tone: "emerald" },
  { who: "Deploy bot", what: "promoted v3.8.1 to production", when: "11:05", tone: "sky" },
  { who: "Marcus Adeyemi", what: "revised the onboarding flow spec", when: "09:42", tone: "indigo" },
  { who: "Lena Whitfield", what: "opened issue #204 · retry storm on webhook delivery", when: "Yesterday", tone: "amber" },
];

interface Member {
  name: string;
  role: string;
  initials: string;
  status: "online" | "away" | "offline";
}

const MEMBERS: Member[] = [
  { name: "Priya Nair", role: "Engineering Lead", initials: "PN", status: "online" },
  { name: "Marcus Adeyemi", role: "Product Designer", initials: "MA", status: "away" },
  { name: "Lena Whitfield", role: "Backend Engineer", initials: "LW", status: "online" },
  { name: "Tomas Berg", role: "QA & Release", initials: "TB", status: "offline" },
];

interface Release {
  version: string;
  title: string;
  date: string;
  channel: "Production" | "Beta";
}

const RELEASES: Release[] = [
  { version: "v3.8.1", title: "Faster onboarding", date: "Jul 14", channel: "Production" },
  { version: "v3.9.0-rc.1", title: "Realtime presence", date: "Jul 15", channel: "Beta" },
  { version: "v3.8.0", title: "Team roles GA", date: "Jul 2", channel: "Production" },
  { version: "v3.7.4", title: "Billing reliability", date: "Jun 21", channel: "Production" },
];

/* ----------------------------------------------------------------------------
   Small styling maps
---------------------------------------------------------------------------- */

const METRIC_TONE: Record<Metric["tone"], string> = {
  emerald: "text-emerald-600 dark:text-emerald-400",
  sky: "text-sky-600 dark:text-sky-400",
  amber: "text-amber-600 dark:text-amber-400",
};

const EVENT_DOT: Record<Event["tone"], string> = {
  indigo: "bg-indigo-500",
  emerald: "bg-emerald-500",
  sky: "bg-sky-500",
  amber: "bg-amber-500",
};

const STATUS_DOT: Record<Member["status"], string> = {
  online: "bg-emerald-500",
  away: "bg-amber-500",
  offline: "bg-zinc-400 dark:bg-zinc-600",
};

const STATUS_LABEL: Record<Member["status"], string> = {
  online: "Online",
  away: "Away",
  offline: "Offline",
};

/* ----------------------------------------------------------------------------
   Panels
---------------------------------------------------------------------------- */

function OverviewPanel(): ReactElement {
  return (
    <div className="grid gap-4">
      <div className="grid gap-3 sm:grid-cols-3">
        {METRICS.map((m) => (
          <div
            key={m.label}
            className="rounded-2xl border border-zinc-200 bg-white/70 p-4 dark:border-white/10 dark:bg-white/[0.03]"
          >
            <p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
              {m.label}
            </p>
            <p className="mt-2 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
              {m.value}
            </p>
            <p className={`mt-1 flex items-center gap-1 text-xs font-medium ${METRIC_TONE[m.tone]}`}>
              <ArrowUpIcon className="h-3.5 w-3.5" />
              {m.note}
            </p>
          </div>
        ))}
      </div>

      <div className="flex items-start gap-3 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-500/25 dark:bg-emerald-500/10">
        <span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
          <CheckIcon className="h-4 w-4" />
        </span>
        <p className="text-sm leading-relaxed text-emerald-900 dark:text-emerald-100">
          <span className="font-semibold">Latency down 38%.</span> The new edge cache cut median
          cold-start from 410&nbsp;ms to 254&nbsp;ms after shipping v3.8.1.
        </p>
      </div>
    </div>
  );
}

function ActivityPanel(): ReactElement {
  return (
    <ol className="relative ml-1.5 space-y-5 border-l border-dashed border-zinc-200 pl-6 dark:border-white/10">
      {EVENTS.map((e) => (
        <li key={e.what} className="relative">
          <span
            className={`absolute -left-[1.65rem] top-1 h-2.5 w-2.5 rounded-full ring-4 ring-white dark:ring-zinc-900 ${EVENT_DOT[e.tone]}`}
          />
          <p className="text-sm leading-snug text-zinc-700 dark:text-zinc-200">
            <span className="font-semibold text-zinc-900 dark:text-zinc-50">{e.who}</span>{" "}
            {e.what}
          </p>
          <p className="mt-0.5 text-xs tabular-nums text-zinc-400 dark:text-zinc-500">{e.when}</p>
        </li>
      ))}
    </ol>
  );
}

function TeamPanel(): ReactElement {
  return (
    <ul className="grid gap-2.5 sm:grid-cols-2">
      {MEMBERS.map((m) => (
        <li
          key={m.name}
          className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white/70 p-3 dark:border-white/10 dark:bg-white/[0.03]"
        >
          <span className="relative grid h-10 w-10 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-semibold text-white">
            {m.initials}
            <span
              className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-white dark:ring-zinc-900 ${STATUS_DOT[m.status]}`}
            />
          </span>
          <div className="min-w-0">
            <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-50">{m.name}</p>
            <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">{m.role}</p>
          </div>
          <span className="ml-auto text-[11px] font-medium text-zinc-400 dark:text-zinc-500">
            {STATUS_LABEL[m.status]}
          </span>
        </li>
      ))}
    </ul>
  );
}

function ReleasesPanel(): ReactElement {
  return (
    <ul className="divide-y divide-zinc-200 overflow-hidden rounded-2xl border border-zinc-200 dark:divide-white/10 dark:border-white/10">
      {RELEASES.map((r) => (
        <li
          key={r.version}
          className="flex items-center gap-3 bg-white/60 px-4 py-3 dark:bg-white/[0.03]"
        >
          <code className="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-semibold tabular-nums text-zinc-700 dark:bg-white/10 dark:text-zinc-200">
            {r.version}
          </code>
          <span className="truncate text-sm text-zinc-800 dark:text-zinc-100">{r.title}</span>
          <span className="ml-auto flex items-center gap-3">
            <span
              className={
                r.channel === "Production"
                  ? "inline-flex items-center rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300"
                  : "inline-flex items-center rounded-full bg-sky-500/12 px-2 py-0.5 text-[11px] font-medium text-sky-700 dark:text-sky-300"
              }
            >
              {r.channel}
            </span>
            <span className="hidden text-xs tabular-nums text-zinc-400 dark:text-zinc-500 sm:inline">
              {r.date}
            </span>
          </span>
        </li>
      ))}
    </ul>
  );
}

function renderPanel(id: TabId): ReactElement {
  switch (id) {
    case "overview":
      return <OverviewPanel />;
    case "activity":
      return <ActivityPanel />;
    case "team":
      return <TeamPanel />;
    case "releases":
      return <ReleasesPanel />;
  }
}

/* ----------------------------------------------------------------------------
   Component
---------------------------------------------------------------------------- */

export default function TabIcons(): ReactElement {
  const [activeId, setActiveId] = useState<TabId>("overview");
  const reduce = useReducedMotion();
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const activeIndex = TABS.findIndex((t) => t.id === activeId);

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

  function onKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>, index: number): void {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        focusTab(index + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        focusTab(index - 1);
        break;
      case "Home":
        e.preventDefault();
        focusTab(0);
        break;
      case "End":
        e.preventDefault();
        focusTab(TABS.length - 1);
        break;
      default:
        break;
    }
  }

  const pillTransition = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 440, damping: 36, mass: 0.7 };

  const contentVariants = {
    hidden: { opacity: 0, y: reduce ? 0 : 10 },
    show: { opacity: 1, y: 0 },
    exit: { opacity: 0, y: reduce ? 0 : -10 },
  };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-zinc-50 via-white to-zinc-100 px-4 py-20 text-zinc-900 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-100 sm:px-6 sm:py-28">
      <style>{`
        @keyframes tabicons-float {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -22px, 0) scale(1.06); }
        }
        @keyframes tabicons-drift {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(26px, 14px, 0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .tabicons-anim { animation: none !important; }
        }
      `}</style>

      {/* Decorative atmosphere */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="tabicons-anim absolute -left-24 top-0 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15 [animation:tabicons-float_19s_ease-in-out_infinite]" />
        <div className="tabicons-anim absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/15 [animation:tabicons-drift_23s_ease-in-out_infinite]" />
        <div
          className="absolute inset-0 opacity-[0.04] dark:opacity-[0.06]"
          style={{
            backgroundImage:
              "linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
            backgroundSize: "44px 44px",
            maskImage: "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
            WebkitMaskImage: "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-3xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-indigo-600 dark:border-white/10 dark:bg-white/[0.04] dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            WORKSPACE
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 sm:text-4xl">
            Northwind Studio
          </h2>
          <p className="mt-2 max-w-xl text-sm leading-relaxed text-zinc-600 dark:text-zinc-400 sm:text-base">
            One place to track shipping velocity, team activity, and releases across the studio.
          </p>
        </header>

        <div className="rounded-3xl border border-zinc-200 bg-white/70 p-3 shadow-xl shadow-zinc-900/5 backdrop-blur dark:border-white/10 dark:bg-zinc-900/50 dark:shadow-black/40 sm:p-4">
          {/* Tablist */}
          <div
            role="tablist"
            aria-label="Workspace sections"
            aria-orientation="horizontal"
            className="flex gap-1 overflow-x-auto rounded-2xl bg-zinc-100/80 p-1.5 ring-1 ring-zinc-200/70 [scrollbar-width:none] dark:bg-black/30 dark:ring-white/10"
          >
            {TABS.map((tab, index) => {
              const selected = tab.id === activeId;
              return (
                <button
                  key={tab.id}
                  ref={(el) => {
                    tabRefs.current[index] = el;
                  }}
                  role="tab"
                  id={`tabicons-tab-${tab.id}`}
                  type="button"
                  aria-selected={selected}
                  aria-controls={`tabicons-panel-${tab.id}`}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setActiveId(tab.id)}
                  onKeyDown={(e) => onKeyDown(e, index)}
                  className={`relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-xl px-3 py-2.5 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-zinc-50"
                      : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100"
                  }`}
                >
                  {selected && (
                    <motion.span
                      layoutId="tabicons-pill"
                      transition={pillTransition}
                      className="absolute inset-0 rounded-xl bg-white shadow-sm ring-1 ring-zinc-200/80 dark:bg-zinc-800 dark:ring-white/10"
                    />
                  )}
                  <span className="relative z-10 flex items-center gap-2">
                    <tab.Icon
                      className={`h-4 w-4 shrink-0 transition-colors ${
                        selected ? "text-indigo-600 dark:text-indigo-400" : ""
                      }`}
                    />
                    <span>{tab.label}</span>
                    {typeof tab.count === "number" && (
                      <span
                        className={`ml-0.5 hidden rounded-full px-1.5 text-[11px] font-semibold leading-5 sm:inline ${
                          selected
                            ? "bg-indigo-600 text-white"
                            : "bg-zinc-200 text-zinc-600 dark:bg-white/10 dark:text-zinc-300"
                        }`}
                      >
                        {tab.count}
                      </span>
                    )}
                  </span>
                </button>
              );
            })}
          </div>

          {/* Panel */}
          <div className="mt-4 min-h-[19rem] rounded-2xl p-1 sm:p-3">
            <div
              role="tabpanel"
              id={`tabicons-panel-${activeId}`}
              aria-labelledby={`tabicons-tab-${activeId}`}
              tabIndex={0}
              className="rounded-xl 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-900"
            >
              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={activeId}
                  variants={contentVariants}
                  initial="hidden"
                  animate="show"
                  exit="exit"
                  transition={{ duration: reduce ? 0 : 0.22, ease: [0.22, 1, 0.36, 1] }}
                >
                  {renderPanel(activeId)}
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        </div>

        <p className="mt-4 px-1 text-xs text-zinc-400 dark:text-zinc-500">
          Tip: focus a tab and use{" "}
          <kbd className="rounded border border-zinc-300 bg-white px-1 font-sans text-[10px] text-zinc-600 dark:border-white/15 dark:bg-white/10 dark:text-zinc-300">
            ←
          </kbd>{" "}
          <kbd className="rounded border border-zinc-300 bg-white px-1 font-sans text-[10px] text-zinc-600 dark:border-white/15 dark:bg-white/10 dark:text-zinc-300">
            →
          </kbd>{" "}
          to move between sections. Panel {activeIndex + 1} of {TABS.length}.
        </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 →