Web InnoventixFreeCode

Closable Tab

Original · free

browser-style closable tabs

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

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type MouseEvent as ReactMouseEvent,
} from "react";

type IconName =
  | "home"
  | "chart"
  | "book"
  | "git"
  | "spark"
  | "back"
  | "forward"
  | "reload"
  | "plus"
  | "close"
  | "lock"
  | "undo"
  | "globe";

type Tone = "emerald" | "amber" | "rose" | "indigo" | "sky" | "violet";

interface Row {
  primary: string;
  secondary: string;
  tag: string;
  tone: Tone;
}

interface TabData {
  title: string;
  url: string;
  icon: IconName;
  kind: "site" | "newtab";
  heading: string;
  subtitle: string;
  rows: Row[];
}

interface TabItem extends TabData {
  id: string;
}

const toneClasses: Record<Tone, string> = {
  emerald:
    "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  amber: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  rose: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
  indigo:
    "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  sky: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  violet:
    "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
};

const SITES = {
  dashboard: {
    title: "Dashboard",
    url: "aurora.app/dashboard",
    icon: "home",
    kind: "site",
    heading: "Good afternoon, Salman",
    subtitle: "Here's what moved across your workspace today.",
    rows: [
      {
        primary: "Deployment #1284",
        secondary: "aurora-web · promoted to production 4 minutes ago",
        tag: "Live",
        tone: "emerald",
      },
      {
        primary: "Weekly active users",
        secondary: "18,204 sessions · up 6.2% from last week",
        tag: "+6.2%",
        tone: "sky",
      },
      {
        primary: "API error rate",
        secondary: "0.11% across 2.4M requests in the last 24h",
        tag: "Healthy",
        tone: "emerald",
      },
      {
        primary: "Billing sync",
        secondary: "Stripe reconciliation queued for 18:00 UTC",
        tag: "Queued",
        tone: "amber",
      },
    ],
  },
  analytics: {
    title: "Analytics",
    url: "aurora.app/analytics",
    icon: "chart",
    kind: "site",
    heading: "Product analytics",
    subtitle: "Last 30 days, compared to the previous period.",
    rows: [
      {
        primary: "New signups",
        secondary: "1,932 accounts created this month",
        tag: "+11%",
        tone: "sky",
      },
      {
        primary: "Activation rate",
        secondary: "64% reached first value in under 10 minutes",
        tag: "Good",
        tone: "emerald",
      },
      {
        primary: "Monthly churn",
        secondary: "2.1% of accounts, down from 2.8%",
        tag: "-0.7pt",
        tone: "emerald",
      },
      {
        primary: "Top referrer",
        secondary: "github.com drove 41% of new traffic",
        tag: "Organic",
        tone: "indigo",
      },
    ],
  },
  changelog: {
    title: "Changelog",
    url: "aurora.app/changelog",
    icon: "book",
    kind: "site",
    heading: "What's new",
    subtitle: "Release notes for the Aurora tab system.",
    rows: [
      {
        primary: "v3.8 — Closable tab groups",
        secondary: "Reopen the last closed tab from the toolbar or keyboard",
        tag: "New",
        tone: "violet",
      },
      {
        primary: "v3.7 — Keyboard navigation",
        secondary: "Arrow keys, Home, End, and Delete now drive the tab strip",
        tag: "Improved",
        tone: "sky",
      },
      {
        primary: "v3.6 — Reduced-motion support",
        secondary: "Every transition respects prefers-reduced-motion",
        tag: "a11y",
        tone: "emerald",
      },
      {
        primary: "v3.5 — Dark theme polish",
        secondary: "Higher contrast on the active tab and address bar",
        tag: "Fix",
        tone: "amber",
      },
    ],
  },
  pulls: {
    title: "Pull requests",
    url: "github.com/aurora-labs/core/pulls",
    icon: "git",
    kind: "site",
    heading: "Open pull requests",
    subtitle: "aurora-labs/core · 4 awaiting your review.",
    rows: [
      {
        primary: "#482 Add roving tabindex to the tablist",
        secondary: "maya-k · 3 files changed · +128 −40",
        tag: "Review",
        tone: "amber",
      },
      {
        primary: "#479 Fix focus loss after closing a tab",
        secondary: "dev-omar · approved by 2 reviewers",
        tag: "Approved",
        tone: "emerald",
      },
      {
        primary: "#475 Animate tab reflow on close",
        secondary: "lena · marked as draft",
        tag: "Draft",
        tone: "indigo",
      },
      {
        primary: "#471 Address-bar autocomplete",
        secondary: "priya · conflicts with main",
        tag: "Conflict",
        tone: "rose",
      },
    ],
  },
} satisfies Record<string, TabData>;

type SiteKey = keyof typeof SITES;

const SITE_ORDER: SiteKey[] = ["dashboard", "analytics", "changelog", "pulls"];

function newTabTemplate(): TabData {
  return {
    title: "New tab",
    url: "aurora://newtab",
    icon: "spark",
    kind: "newtab",
    heading: "Open a workspace",
    subtitle: "Pick up where you left off, or start something fresh.",
    rows: [],
  };
}

function Icon({ name, className }: { name: IconName; className?: string }) {
  const common = {
    className,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.7,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    "aria-hidden": true,
    focusable: false,
  };
  switch (name) {
    case "home":
      return (
        <svg {...common}>
          <path d="M3 10.75 12 3l9 7.75" />
          <path d="M5.5 9.4V21h13V9.4" />
          <path d="M9.75 21v-5.5h4.5V21" />
        </svg>
      );
    case "chart":
      return (
        <svg {...common}>
          <path d="M3.5 20.5h17" />
          <path d="M6 20.5V12" />
          <path d="M12 20.5V5" />
          <path d="M18 20.5v-9" />
        </svg>
      );
    case "book":
      return (
        <svg {...common}>
          <path d="M12 6.4C10.5 5 8 4.5 4.6 4.8V18c3.4-.3 5.9.2 7.4 1.7" />
          <path d="M12 6.4C13.5 5 16 4.5 19.4 4.8V18c-3.4-.3-5.9.2-7.4 1.7" />
          <path d="M12 6.4v13.3" />
        </svg>
      );
    case "git":
      return (
        <svg {...common}>
          <circle cx="6.5" cy="6" r="2.3" />
          <circle cx="6.5" cy="18" r="2.3" />
          <path d="M6.5 8.3v7.4" />
          <circle cx="17.5" cy="18" r="2.3" />
          <path d="M17.5 15.7v-4.4a4 4 0 0 0-4-4h-3" />
          <path d="M11.6 5.1 9.9 7.3 12.1 9" />
        </svg>
      );
    case "spark":
      return (
        <svg {...common}>
          <path d="M12 3.2l1.9 5.3 5.3 1.9-5.3 1.9L12 17.5l-1.9-5.2-5.3-1.9 5.3-1.9z" />
          <path d="M18.6 4.2l.6 1.7 1.7.6-1.7.6-.6 1.7-.6-1.7-1.7-.6 1.7-.6z" />
        </svg>
      );
    case "back":
      return (
        <svg {...common}>
          <path d="M15 5.5 8.5 12l6.5 6.5" />
        </svg>
      );
    case "forward":
      return (
        <svg {...common}>
          <path d="M9 5.5 15.5 12 9 18.5" />
        </svg>
      );
    case "reload":
      return (
        <svg {...common}>
          <path d="M19.6 8.6A7.5 7.5 0 1 0 20.5 13" />
          <path d="M20.5 4v5h-5" />
        </svg>
      );
    case "plus":
      return (
        <svg {...common}>
          <path d="M12 5v14" />
          <path d="M5 12h14" />
        </svg>
      );
    case "close":
      return (
        <svg {...common}>
          <path d="M6.5 6.5l11 11" />
          <path d="M17.5 6.5l-11 11" />
        </svg>
      );
    case "lock":
      return (
        <svg {...common}>
          <rect x="5" y="11" width="14" height="9" rx="2" />
          <path d="M8 11V8a4 4 0 0 1 8 0v3" />
        </svg>
      );
    case "undo":
      return (
        <svg {...common}>
          <path d="M9 7 4 12l5 5" />
          <path d="M4 12h11a5 5 0 0 1 0 10h-1.5" />
        </svg>
      );
    case "globe":
      return (
        <svg {...common}>
          <circle cx="12" cy="12" r="8.4" />
          <path d="M3.6 12h16.8" />
          <path d="M12 3.6c2.4 2.3 3.7 5.3 3.7 8.4S14.4 18.1 12 20.4c-2.4-2.3-3.7-5.3-3.7-8.4S9.6 5.9 12 3.6z" />
        </svg>
      );
    default:
      return null;
  }
}

export default function TabClosable() {
  const baseId = useId();
  const reduce = useReducedMotion() === true;

  const [tabs, setTabs] = useState<TabItem[]>(() =>
    SITE_ORDER.slice(0, 4).map((key, i) => ({ id: `seed-${i}`, ...SITES[key] })),
  );
  const [activeId, setActiveId] = useState<string>("seed-0");
  const [closedStack, setClosedStack] = useState<TabData[]>([]);
  const [pendingFocus, setPendingFocus] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const counter = useRef(1);
  const tabRefs = useRef(new Map<string, HTMLDivElement>());
  const loadTimer = useRef<number | null>(null);

  const nextId = () => `tab-${counter.current++}`;
  const panelId = `${baseId}-panel`;
  const tabDomId = (id: string) => `${baseId}-tab-${id}`;

  const activeTab = tabs.find((t) => t.id === activeId) ?? tabs[0];

  useEffect(() => {
    if (pendingFocus) {
      tabRefs.current.get(pendingFocus)?.focus();
      setPendingFocus(null);
    }
  }, [pendingFocus, tabs]);

  useEffect(
    () => () => {
      if (loadTimer.current) window.clearTimeout(loadTimer.current);
    },
    [],
  );

  const triggerLoad = useCallback(() => {
    setLoading(true);
    if (loadTimer.current) window.clearTimeout(loadTimer.current);
    loadTimer.current = window.setTimeout(
      () => setLoading(false),
      reduce ? 0 : 720,
    );
  }, [reduce]);

  const selectAndFocus = useCallback((id: string) => {
    setActiveId(id);
    setPendingFocus(id);
  }, []);

  const closeTab = useCallback(
    (id: string) => {
      const idx = tabs.findIndex((t) => t.id === id);
      if (idx === -1) return;
      const closed: TabData = { ...tabs[idx] };
      const next = tabs.filter((t) => t.id !== id);
      setClosedStack((c) => [closed, ...c].slice(0, 8));

      if (next.length === 0) {
        const nid = nextId();
        setTabs([{ id: nid, ...newTabTemplate() }]);
        setActiveId(nid);
        setPendingFocus(nid);
        return;
      }

      setTabs(next);
      if (activeId === id) {
        const neighbor = next[Math.min(idx, next.length - 1)];
        setActiveId(neighbor.id);
        setPendingFocus(neighbor.id);
      } else {
        setPendingFocus(activeId);
      }
    },
    [tabs, activeId],
  );

  const addTab = useCallback(() => {
    const nid = nextId();
    setTabs((prev) => [...prev, { id: nid, ...newTabTemplate() }]);
    setActiveId(nid);
    setPendingFocus(nid);
    triggerLoad();
  }, [triggerLoad]);

  const reopenTab = useCallback(() => {
    if (closedStack.length === 0) return;
    const [first, ...rest] = closedStack;
    const nid = nextId();
    setTabs((prev) => [...prev, { id: nid, ...first }]);
    setClosedStack(rest);
    setActiveId(nid);
    setPendingFocus(nid);
    triggerLoad();
  }, [closedStack, triggerLoad]);

  const navigate = useCallback(
    (key: SiteKey) => {
      setTabs((prev) =>
        prev.map((t) => (t.id === activeId ? { ...SITES[key], id: t.id } : t)),
      );
      triggerLoad();
    },
    [activeId, triggerLoad],
  );

  const setUrl = useCallback(
    (value: string) => {
      setTabs((prev) =>
        prev.map((t) => (t.id === activeId ? { ...t, url: value } : t)),
      );
    },
    [activeId],
  );

  const onTabKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {
      const { key } = event;
      if (key === "ArrowRight" || key === "ArrowLeft") {
        event.preventDefault();
        const dir = key === "ArrowRight" ? 1 : -1;
        const nextIndex = (index + dir + tabs.length) % tabs.length;
        selectAndFocus(tabs[nextIndex].id);
      } else if (key === "Home") {
        event.preventDefault();
        selectAndFocus(tabs[0].id);
      } else if (key === "End") {
        event.preventDefault();
        selectAndFocus(tabs[tabs.length - 1].id);
      } else if (key === "Delete" || key === "Backspace") {
        event.preventDefault();
        closeTab(tabs[index].id);
      } else if (key === "Enter" || key === " ") {
        event.preventDefault();
        selectAndFocus(tabs[index].id);
      }
    },
    [tabs, selectAndFocus, closeTab],
  );

  const spring = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 520, damping: 40, mass: 0.7 };

  const focusRing =
    "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-indigo-400 dark:focus-visible:ring-offset-zinc-900";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:py-24 dark:from-zinc-950 dark:to-black">
      <style>{`
        @keyframes bctab-spin { to { transform: rotate(360deg); } }
        @keyframes bctab-shimmer {
          0% { background-position: -160% 0; }
          100% { background-position: 260% 0; }
        }
        .bctab-spin { animation: bctab-spin 0.72s linear infinite; }
        .bctab-shimmer {
          background-image: linear-gradient(
            110deg,
            rgba(255,255,255,0) 20%,
            rgba(255,255,255,0.55) 50%,
            rgba(255,255,255,0) 80%
          );
          background-size: 200% 100%;
          animation: bctab-shimmer 1s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .bctab-spin, .bctab-shimmer { animation: none !important; }
        }
      `}</style>

      <div className="relative mx-auto w-full max-w-5xl">
        <div className="mb-8 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Browser-style tabs
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-zinc-50">
            Closable session tabs
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
            Open, close, and reopen tabs like a real browser. Drive the strip
            with the mouse or the keyboard: arrow keys to switch, Delete to
            close, and the restore button to bring one back.
          </p>
        </div>

        {/* Browser window */}
        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
          {/* Tab strip */}
          <div className="flex items-end gap-1 bg-slate-100 px-3 pt-2.5 dark:bg-zinc-950">
            <div
              className="mb-2 mr-2 hidden shrink-0 items-center gap-1.5 sm:flex"
              aria-hidden="true"
            >
              <span className="h-3 w-3 rounded-full bg-rose-400/90" />
              <span className="h-3 w-3 rounded-full bg-amber-400/90" />
              <span className="h-3 w-3 rounded-full bg-emerald-400/90" />
            </div>

            <div
              role="tablist"
              aria-label="Open browser tabs"
              aria-orientation="horizontal"
              className="flex min-w-0 flex-1 items-end gap-1 overflow-x-auto"
            >
              <AnimatePresence initial={false} mode="popLayout">
                {tabs.map((tab, index) => {
                  const active = tab.id === activeId;
                  return (
                    <motion.div
                      key={tab.id}
                      layout={!reduce}
                      initial={reduce ? false : { opacity: 0, scale: 0.85, y: 6 }}
                      animate={reduce ? {} : { opacity: 1, scale: 1, y: 0 }}
                      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
                      transition={spring}
                      ref={(el: HTMLDivElement | null) => {
                        if (el) tabRefs.current.set(tab.id, el);
                        else tabRefs.current.delete(tab.id);
                      }}
                      role="tab"
                      id={tabDomId(tab.id)}
                      aria-selected={active}
                      aria-controls={panelId}
                      tabIndex={active ? 0 : -1}
                      onClick={() => selectAndFocus(tab.id)}
                      onKeyDown={(e) => onTabKeyDown(e, index)}
                      onAuxClick={(e: ReactMouseEvent<HTMLDivElement>) => {
                        if (e.button === 1) {
                          e.preventDefault();
                          closeTab(tab.id);
                        }
                      }}
                      className={[
                        "group relative flex w-40 shrink-0 cursor-pointer select-none items-center gap-2 rounded-t-lg px-3 py-2 text-sm transition-colors",
                        focusRing,
                        active
                          ? "z-10 -mb-px bg-white text-slate-900 shadow-[0_-1px_0_rgba(0,0,0,0.04)] dark:bg-zinc-900 dark:text-zinc-50"
                          : "bg-slate-200/60 text-slate-500 hover:bg-slate-200 dark:bg-zinc-800/50 dark:text-zinc-400 dark:hover:bg-zinc-800",
                      ].join(" ")}
                    >
                      <span
                        className={[
                          "grid h-4 w-4 shrink-0 place-items-center",
                          active
                            ? "text-indigo-500 dark:text-indigo-400"
                            : "text-slate-400 dark:text-zinc-500",
                        ].join(" ")}
                      >
                        <Icon name={tab.icon} className="h-4 w-4" />
                      </span>
                      <span className="min-w-0 flex-1 truncate font-medium">
                        {tab.title}
                      </span>
                      <button
                        type="button"
                        tabIndex={active ? 0 : -1}
                        aria-label={`Close ${tab.title}`}
                        onClick={(e) => {
                          e.stopPropagation();
                          closeTab(tab.id);
                        }}
                        onKeyDown={(e) => e.stopPropagation()}
                        onMouseDown={(e) => e.stopPropagation()}
                        className={[
                          "grid h-5 w-5 shrink-0 place-items-center rounded-md text-slate-500 transition hover:bg-slate-300/60 hover:text-slate-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-50",
                          focusRing,
                          active
                            ? "opacity-70"
                            : "opacity-0 group-hover:opacity-70 group-focus-within:opacity-70",
                        ].join(" ")}
                      >
                        <Icon name="close" className="h-3.5 w-3.5" />
                      </button>
                    </motion.div>
                  );
                })}
              </AnimatePresence>

              <button
                type="button"
                onClick={addTab}
                aria-label="Open a new tab"
                title="New tab"
                className={[
                  "mb-1 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-slate-200 hover:text-slate-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
                  focusRing,
                ].join(" ")}
              >
                <Icon name="plus" className="h-4 w-4" />
              </button>
            </div>
          </div>

          {/* Toolbar / address bar */}
          <div className="flex items-center gap-2 border-b border-slate-200 bg-white px-3 py-2.5 dark:border-zinc-800 dark:bg-zinc-900">
            <div className="flex items-center gap-0.5">
              <button
                type="button"
                disabled
                aria-label="Go back"
                className="grid h-8 w-8 place-items-center rounded-lg text-slate-300 dark:text-zinc-700"
              >
                <Icon name="back" className="h-4 w-4" />
              </button>
              <button
                type="button"
                disabled
                aria-label="Go forward"
                className="grid h-8 w-8 place-items-center rounded-lg text-slate-300 dark:text-zinc-700"
              >
                <Icon name="forward" className="h-4 w-4" />
              </button>
              <button
                type="button"
                onClick={triggerLoad}
                aria-label="Reload this tab"
                title="Reload"
                className={[
                  "grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
                  focusRing,
                ].join(" ")}
              >
                <Icon
                  name="reload"
                  className={`h-4 w-4 ${loading ? "bctab-spin" : ""}`}
                />
              </button>
            </div>

            <form
              className="flex min-w-0 flex-1"
              onSubmit={(e) => {
                e.preventDefault();
                triggerLoad();
                (
                  document.activeElement as HTMLElement | null
                )?.blur();
              }}
            >
              <label htmlFor={`${baseId}-omnibox`} className="sr-only">
                Address and search bar
              </label>
              <div
                className={[
                  "flex min-w-0 flex-1 items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3.5 py-1.5 transition focus-within:border-indigo-400 focus-within:bg-white dark:border-zinc-700 dark:bg-zinc-800/70 dark:focus-within:border-indigo-400 dark:focus-within:bg-zinc-800",
                ].join(" ")}
              >
                <Icon
                  name={activeTab.kind === "newtab" ? "globe" : "lock"}
                  className="h-3.5 w-3.5 shrink-0 text-emerald-500 dark:text-emerald-400"
                />
                <input
                  id={`${baseId}-omnibox`}
                  value={activeTab.url}
                  onChange={(e) => setUrl(e.target.value)}
                  spellCheck={false}
                  autoComplete="off"
                  className="min-w-0 flex-1 bg-transparent text-sm text-slate-700 outline-none placeholder:text-slate-400 dark:text-zinc-200 dark:placeholder:text-zinc-500"
                  placeholder="Search or enter address"
                />
              </div>
            </form>

            <button
              type="button"
              onClick={reopenTab}
              disabled={closedStack.length === 0}
              aria-label={
                closedStack.length === 0
                  ? "No closed tabs to reopen"
                  : `Reopen last closed tab (${closedStack.length} available)`
              }
              title="Reopen closed tab"
              className={[
                "relative grid h-8 w-8 shrink-0 place-items-center rounded-lg transition",
                focusRing,
                closedStack.length === 0
                  ? "text-slate-300 dark:text-zinc-700"
                  : "text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
              ].join(" ")}
            >
              <Icon name="undo" className="h-4 w-4" />
              {closedStack.length > 0 && (
                <span className="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-indigo-500 px-1 text-[10px] font-semibold leading-none text-white">
                  {closedStack.length}
                </span>
              )}
            </button>
          </div>

          {/* Content panel */}
          <div
            role="tabpanel"
            id={panelId}
            aria-labelledby={tabDomId(activeTab.id)}
            tabIndex={0}
            className={[
              "relative min-h-[22rem] bg-white px-5 py-6 sm:px-8 sm:py-8 dark:bg-zinc-900",
              focusRing,
            ].join(" ")}
          >
            <AnimatePresence>
              {loading && (
                <motion.div
                  key="loadbar"
                  className="absolute inset-x-0 top-0 h-[3px] overflow-hidden bg-slate-100 dark:bg-zinc-800"
                  initial={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: reduce ? 0 : 0.2 }}
                >
                  <motion.div
                    className="h-full rounded-r-full bg-indigo-500 bctab-shimmer dark:bg-indigo-400"
                    initial={{ width: reduce ? "100%" : "8%" }}
                    animate={{ width: "100%" }}
                    transition={{ duration: reduce ? 0 : 0.7, ease: "easeOut" }}
                  />
                </motion.div>
              )}
            </AnimatePresence>

            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={`${activeTab.id}-${activeTab.url}`}
                initial={reduce ? false : { opacity: 0, y: 10 }}
                animate={reduce ? {} : { opacity: 1, y: 0 }}
                exit={reduce ? {} : { opacity: 0, y: -8 }}
                transition={{ duration: reduce ? 0 : 0.24, ease: "easeOut" }}
              >
                <div className="flex items-start gap-3">
                  <span className="mt-0.5 grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-indigo-50 text-indigo-600 ring-1 ring-inset ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
                    <Icon name={activeTab.icon} className="h-5 w-5" />
                  </span>
                  <div className="min-w-0">
                    <h3 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
                      {activeTab.heading}
                    </h3>
                    <p className="mt-0.5 text-sm text-slate-500 dark:text-zinc-400">
                      {activeTab.subtitle}
                    </p>
                  </div>
                </div>

                {activeTab.kind === "newtab" ? (
                  <div className="mt-6">
                    <p className="mb-3 text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-zinc-500">
                      Frequently visited
                    </p>
                    <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
                      {SITE_ORDER.map((key) => {
                        const site = SITES[key];
                        return (
                          <button
                            key={key}
                            type="button"
                            onClick={() => navigate(key)}
                            className={[
                              "group flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50/60 px-4 py-3 text-left transition hover:border-indigo-300 hover:bg-white dark:border-zinc-800 dark:bg-zinc-800/40 dark:hover:border-indigo-500/40 dark:hover:bg-zinc-800",
                              focusRing,
                            ].join(" ")}
                          >
                            <span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-white text-slate-600 ring-1 ring-inset ring-slate-200 transition group-hover:text-indigo-600 dark:bg-zinc-900 dark:text-zinc-300 dark:ring-zinc-700 dark:group-hover:text-indigo-300">
                              <Icon name={site.icon} className="h-4 w-4" />
                            </span>
                            <span className="min-w-0">
                              <span className="block truncate text-sm font-medium text-slate-800 dark:text-zinc-100">
                                {site.title}
                              </span>
                              <span className="block truncate text-xs text-slate-400 dark:text-zinc-500">
                                {site.url}
                              </span>
                            </span>
                          </button>
                        );
                      })}
                    </div>
                  </div>
                ) : (
                  <ul className="mt-6 divide-y divide-slate-100 dark:divide-zinc-800">
                    {activeTab.rows.map((row) => (
                      <li
                        key={row.primary}
                        className="flex items-center justify-between gap-4 py-3"
                      >
                        <div className="min-w-0">
                          <p className="truncate text-sm font-medium text-slate-800 dark:text-zinc-100">
                            {row.primary}
                          </p>
                          <p className="truncate text-xs text-slate-500 dark:text-zinc-400">
                            {row.secondary}
                          </p>
                        </div>
                        <span
                          className={[
                            "shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold",
                            toneClasses[row.tone],
                          ].join(" ")}
                        >
                          {row.tag}
                        </span>
                      </li>
                    ))}
                  </ul>
                )}
              </motion.div>
            </AnimatePresence>
          </div>

          {/* Status bar */}
          <div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-4 py-2 text-xs text-slate-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-500">
            <span>
              {tabs.length} {tabs.length === 1 ? "tab" : "tabs"} open
            </span>
            <span className="hidden sm:inline">
              Arrow keys switch · Delete closes · Restore brings one back
            </span>
          </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 →