← ui

tabs

ui

views of one thing, one at a time: a segment or a line, swiped between, panels kept

npx shadcn add @ag/tabs
Already installed? Preview an update
npx shadcn add @ag/tabs --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-15
  • New: TabsPanels, a track the TabsPanels stand straight in, one for each tab, swiped between with a finger or two on a trackpad. It is the lightbox's own track (slide-track, in @ag/lightbox-motion, which the item now installs), the one engine both drive: the panel follows the hand toward a short line, one tab a swipe, held at the first and the last tab without moving past them, and every move glides from where the panels stand to rest exactly on one. Up and down stays the page's, a sideways scroller or a slider inside a panel keeps its own gestures, a mouse drag stays a text selection, and a press, a click and the keys are unchanged; choosing by swipe is pressing the tab, so links and a controlled value work as they do, and a tab pressed glides the track there on the same curve. To adopt it, wrap the panels: <TabsPanels>{panels}</TabsPanels> inside the Panel (or the Tabs).
  • In a track every panel stays laid out beside the others, so showing a tab no longer rebuilds its panel's style and layout; one not shown is inert. The track is as tall as the panel shown. A mount="active" panel keeps its element there (its place on the track), its content mounted only while shown; mount="seen" also mounts the panels either side once a swipe starts.
  • A sideways wheel swipe over the tab bar (anywhere in the Tabs) swipes between the panels, as one over a panel does; nested tabs keep their own.
  • In a panel, TabsPanels runs to the panel's edge: the track reaches over the body's padding, read off the body itself (p-5, or whatever its bodyClassName makes it), and each panel puts it back inside itself. A swipe no longer shows the panels clipped in a line inside the frame, and the padding beside them takes a swipe too. Panel marks the element whose padding is its body's (data-panel-pad).
  • TabsPanels takes onTrace: every decision of its swipe (the stream's axis, a finger's, the engine's lines), for a page that shows or keeps them.
Updated 2026-10-15 · release notes

also called tabbed container, tab box, tab bar

shelf controls

uses material, panel, lightbox-motion

used by components

the demo above, verbatim

tabs.demo.tsx
"use client"

import {
  AtSign,
  Clock,
  CodeXml,
  Folder,
  Globe,
  MapPin,
  Play,
  Send,
  Star,
  Users,
} from "lucide-react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import * as React from "react"
import { Sample } from "@/app/samples"
import type { Deployment } from "@/registry/base-nova/lib/deployment-core"
import type { GithubProfile } from "@/registry/base-nova/lib/github-data"
import type { ProfileFacts } from "@/registry/base-nova/lib/social-profile-core"
import type { LogLine, TraceSpan } from "@/registry/base-nova/lib/telemetry"
import type { Unfurl } from "@/registry/base-nova/lib/web-preview-facts"
import { Badge } from "@/registry/base-nova/ui/badge"
import { Button } from "@/registry/base-nova/ui/button"
import { DeploymentList } from "@/registry/base-nova/ui/deployment"
import { GithubProfileCard } from "@/registry/base-nova/ui/github"
import { Log } from "@/registry/base-nova/ui/log"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { SocialProfile } from "@/registry/base-nova/ui/social-profile"
import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsPanels,
  TabsTab,
  type TabsVariant,
  useTabsPanelActive,
} from "@/registry/base-nova/ui/tabs"
import { Textarea } from "@/registry/base-nova/ui/textarea"
import { Trace } from "@/registry/base-nova/ui/trace"
import { WebPreview } from "@/registry/base-nova/ui/web-preview"
import { YouTube } from "@/registry/base-nova/ui/youtube"
import GITHUB_FACTS from "./github.demo.json"

// #region views
function Views({ variant }: { variant?: TabsVariant }) {
  return (
    <Tabs defaultValue="files" variant={variant}>
      <TabsList>
        <TabsTab value="files">files</TabsTab>
        <TabsTab value="people">people</TabsTab>
        <TabsTab value="places">places</TabsTab>
        <TabsTab value="trash" disabled>
          trash
        </TabsTab>
      </TabsList>
      <TabsPanel value="files" className="text-[13px] opacity-75">
        151 files, 45 of them videos.
      </TabsPanel>
      <TabsPanel value="people" className="text-[13px] opacity-75">
        five people, each named once.
      </TabsPanel>
      <TabsPanel value="places" className="text-[13px] opacity-75">
        three disks, one of them offline.
      </TabsPanel>
    </Tabs>
  )
}
// #endregion

// #region icon-tabs
const SHELVES = [
  { value: "files", icon: <Folder />, words: "files", says: "151 files." },
  { value: "people", icon: <Users />, words: "people", says: "five people." },
  { value: "places", icon: <MapPin />, words: "places", says: "three cities." },
  { value: "recent", icon: <Clock />, words: "recent", says: "today, twice." },
  { value: "starred", icon: <Star />, words: "starred", says: "nothing yet." },
]

function IconTabs() {
  return (
    <Tabs defaultValue="files" labels="active">
      <TabsList>
        {SHELVES.map((p) => (
          <TabsTab key={p.value} value={p.value} icon={p.icon}>
            {p.words}
          </TabsTab>
        ))}
      </TabsList>
      {SHELVES.map((p) => (
        <TabsPanel
          key={p.value}
          value={p.value}
          className="text-[13px] opacity-75"
        >
          {p.says}
        </TabsPanel>
      ))}
    </Tabs>
  )
}
// #endregion

// #region kept
const DISKS = Array.from({ length: 24 }, (_, i) => `disk ${i + 1}`)

// Fetches on mount, which is on first show: once, however often you come back.
function Report() {
  const [rows, setRows] = React.useState<string[] | null>(null)
  React.useEffect(() => {
    const answer = window.setTimeout(() => setRows(DISKS), 800)
    return () => window.clearTimeout(answer)
  }, [])
  if (!rows) return <p className="text-foreground/55">loading…</p>
  return (
    <ScrollArea className="max-h-40 space-y-1 px-1 py-5 font-mono text-[12px]">
      {rows.map((row) => (
        <p key={row}>{row}, healthy</p>
      ))}
    </ScrollArea>
  )
}

// Mounted while hidden, so it asks whether it is shown before it polls.
function Live() {
  const active = useTabsPanelActive()
  const [checks, setChecks] = React.useState(0)
  React.useEffect(() => {
    // A demo stops at 30; a real one polls on.
    if (!active || checks >= 30) return
    const next = window.setTimeout(() => setChecks((n) => n + 1), 2000)
    return () => window.clearTimeout(next)
  }, [active, checks])
  return (
    <p className="text-foreground/75">
      checked {checks} times, every 2 s while shown, never while hidden.
    </p>
  )
}

function Kept() {
  return (
    <section className="space-y-2.5 text-[13px]">
      <h3 className="font-medium text-sm">node-07</h3>
      <Tabs defaultValue="note">
        <Panel
          glide
          head={
            <TabsList>
              <TabsTab value="note">note</TabsTab>
              <TabsTab value="report">report</TabsTab>
              <TabsTab value="live">live</TabsTab>
              <TabsTab value="archive">archive</TabsTab>
            </TabsList>
          }
        >
          <TabsPanels>
            <TabsPanel value="note">
              <Textarea placeholder="type, switch tabs, come back" />
            </TabsPanel>
            <TabsPanel value="report">
              <Report />
            </TabsPanel>
            <TabsPanel value="live">
              <Live />
            </TabsPanel>
            <TabsPanel value="archive" mount="active">
              <Textarea placeholder="mount active: starts over on every visit" />
            </TabsPanel>
          </TabsPanels>
        </Panel>
      </Tabs>
    </section>
  )
}
// #endregion

// #region machines
const ZONES = ["north", "east", "south", "west"] as const
const STATES = ["running", "running", "running", "idle", "draining"] as const

const MACHINES = Array.from({ length: 40 }, (_, i) => ({
  name: `node-${String(i + 1).padStart(2, "0")}`,
  zone: ZONES[i % ZONES.length],
  state: STATES[(i * 7) % STATES.length],
  load: (i * 37) % 100,
  temp: 41 + ((i * 13) % 34),
  uptime: `${1 + ((i * 11) % 90)} d`,
}))

const JOBS = Array.from({ length: 18 }, (_, i) => ({
  id: `job ${4200 + i * 3}`,
  owner: ["ana", "ben", "chen", "dara"][i % 4],
  done: (i * 23) % 101,
}))

const ALERTS = [
  "node-07 ran over 70 °C for 4 min",
  "node-22 lost its link to east, back after 12 s",
  "node-31 is draining, 3 jobs left",
]

// The outer cells run to the body's edge, so the columns line up with the title.
const CELL = "px-2 py-1.5 text-left font-normal first:pl-0 last:pr-0"
// #endregion

// #region dashboard
/** A section of a page under its pinned header. The page pins the section's heading
 *  under its header (the page's layout, not the tabs'), and tells the panel to pin
 *  its head below both: `pin` is where the page's pinned things end. */
function Fleet() {
  const heading = React.useId()
  return (
    <section aria-labelledby={heading} className="[--section-title:2.5rem]">
      <h3
        id={heading}
        className="sticky top-(--header-height) z-20 flex h-(--section-title) items-center bg-background font-medium text-sm"
      >
        fleet
      </h3>
      <Machines />
    </section>
  )
}

function Machines() {
  return (
    <Tabs defaultValue="machines" variant="line">
      <Panel
        glide
        pin="calc(var(--header-height) + var(--section-title))"
        action={
          <Button size="sm" variant="outline">
            refresh
          </Button>
        }
        head={
          <TabsList>
            <TabsTab value="machines">machines</TabsTab>
            <TabsTab value="jobs">jobs</TabsTab>
            <TabsTab value="alerts">
              alerts
              <Badge variant="warning" count={ALERTS.length} />
            </TabsTab>
          </TabsList>
        }
      >
        <TabsPanel value="machines">
          <table className="w-full font-mono text-[12px] tabular-nums">
            {/* The column names pin under the panel's head (--panel-under), on the
              card's own colour so the rows pass under them. */}
            <thead className="sticky top-(--panel-under) z-1 bg-background bg-[linear-gradient(var(--color-card),var(--color-card))] text-foreground/55 shadow-[inset_0_-1px_var(--color-border)]">
              <tr>
                <th className={CELL}>name</th>
                <th className={CELL}>zone</th>
                <th className={CELL}>state</th>
                <th className={`${CELL} text-right`}>load</th>
                <th className={`${CELL} text-right`}>temp</th>
                <th className={`${CELL} text-right`}>up</th>
              </tr>
            </thead>
            <tbody>
              {MACHINES.map((m) => (
                <tr
                  key={m.name}
                  className="border-border border-t first:border-t-0"
                >
                  <td className={CELL}>{m.name}</td>
                  <td className={CELL}>{m.zone}</td>
                  <td className={CELL}>{m.state}</td>
                  <td className={`${CELL} text-right`}>{m.load}%</td>
                  <td className={`${CELL} text-right`}>{m.temp} °C</td>
                  <td className={`${CELL} text-right`}>{m.uptime}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </TabsPanel>
        <TabsPanel value="jobs">
          <ul className="divide-y divide-border font-mono text-[12px] tabular-nums">
            {JOBS.map((j) => (
              <li key={j.id} className="flex justify-between py-1.5">
                <span>{j.id}</span>
                <span className="text-foreground/55">{j.owner}</span>
                <span>{j.done}%</span>
              </li>
            ))}
          </ul>
        </TabsPanel>
        <TabsPanel value="alerts">
          <ul className="space-y-2 text-[13px]">
            {ALERTS.map((a) => (
              <li key={a}>{a}</li>
            ))}
          </ul>
        </TabsPanel>
      </Panel>
    </Tabs>
  )
}
// #endregion

// #region capped
const STARTED = Date.UTC(2026, 9, 12, 9, 41, 7)

/** Spans written in ms from the request's start, placed on the clock its lines share. */
const onClock = (spans: readonly TraceSpan[]): TraceSpan[] =>
  spans.map((s) => ({ ...s, from: STARTED + s.from, to: STARTED + s.to }))

/** One checkout that failed: its spans. */
const SPANS = onClock([
  {
    id: "req",
    name: "POST /checkout",
    service: "web",
    from: 0,
    to: 412,
    status: "error",
  },
  {
    id: "auth",
    parentId: "req",
    name: "auth.verify",
    service: "auth",
    from: 4,
    to: 22,
    status: "ok",
  },
  {
    id: "cart",
    parentId: "req",
    name: "GET /cart",
    service: "cart",
    from: 24,
    to: 96,
    status: "ok",
  },
  {
    id: "rows",
    parentId: "cart",
    name: "SELECT cart_items",
    service: "postgres",
    from: 31,
    to: 82,
    status: "ok",
    attributes: { rows: 3 },
  },
  {
    id: "quote",
    parentId: "req",
    name: "price.quote",
    service: "pricing",
    from: 98,
    to: 141,
    status: "ok",
  },
  {
    id: "tax",
    parentId: "quote",
    name: "tax.rate",
    service: "pricing",
    from: 104,
    to: 118,
    status: "ok",
    attributes: { region: "ES" },
  },
  {
    id: "charge",
    parentId: "req",
    name: "charge",
    service: "payments",
    from: 144,
    to: 398,
    status: "error",
    attributes: { amount: "84.00 EUR", attempts: 2 },
  },
  {
    id: "try1",
    parentId: "charge",
    name: "POST /v1/charges",
    service: "stripe",
    from: 150,
    to: 300,
    status: "error",
    attributes: { "http.status": 504, timeout_ms: 150 },
  },
  {
    id: "try2",
    parentId: "charge",
    name: "POST /v1/charges (retry)",
    service: "stripe",
    from: 306,
    to: 392,
    status: "error",
    attributes: { "http.status": 402, code: "card_declined" },
  },
  {
    id: "event",
    parentId: "req",
    name: "events.publish",
    service: "web",
    from: 400,
    to: 409,
    status: "ok",
  },
  {
    id: "mail",
    parentId: "req",
    name: "mail.declined",
    service: "mail",
    from: 405,
    to: 530,
    status: "ok",
    detached: "queued: the request does not wait for it",
  },
])

const NAMES = new Map(SPANS.map((s) => [s.id, s.name]))

/** What the same checkout's services wrote while it ran, each line in the span it
 *  was written in: ms from the request's start, level, span, source, message. */
const LINES: LogLine[] = (
  [
    [
      0,
      "info",
      "req",
      "web",
      "POST /checkout started",
      { request: "req_8f2a" },
    ],
    [4, "debug", "auth", "auth", "token verified", { user: "u_1042" }],
    [24, "debug", "cart", "cart", "loading cart c_77"],
    [82, "debug", "rows", "postgres", "SELECT cart_items: 3 rows in 51 ms"],
    [96, "info", "cart", "cart", "cart c_77 has 3 items"],
    [104, "debug", "tax", "pricing", "tax rate ES 21%"],
    [141, "info", "quote", "pricing", "quoted 84.00 EUR"],
    [144, "info", "charge", "payments", "charging 84.00 EUR", { attempt: 1 }],
    [
      300,
      "warn",
      "try1",
      "payments",
      "stripe timed out after 150 ms; retrying",
      { "http.status": 504 },
    ],
    [306, "info", "try2", "payments", "charging 84.00 EUR", { attempt: 2 }],
    [
      392,
      "error",
      "try2",
      "payments",
      "card declined",
      { code: "card_declined", "http.status": 402 },
    ],
    [398, "error", "charge", "payments", "charge failed after 2 attempts"],
    [400, "info", "event", "web", "published checkout.declined"],
    [405, "debug", "mail", "mail", "queued declined mail to u_1042"],
    [
      412,
      "error",
      "req",
      "web",
      "POST /checkout 402 in 412 ms",
      { request: "req_8f2a" },
    ],
    [530, "info", "mail", "mail", "sent declined mail to u_1042"],
  ] as const
).map(([ms, level, span, source, message, fields], i) => ({
  id: `l${i}`,
  at: STARTED + ms,
  level,
  span: { id: span, name: NAMES.get(span) as string },
  source,
  message,
  fields,
}))

/** The deploys of the services the checkout runs on, newest first: one every 7
 *  minutes, every ninth failing, the newest of each service's production the current
 *  one. */
const SERVICES = ["web", "cart", "payments"] as const
const MESSAGES = [
  "fix(charge): retry a 504 once",
  "feat(cart): keep the cart for 30 days",
  "chore: bump stripe-node",
  "perf(quote): cache the tax rate per region",
  "fix(auth): refresh the token before it expires",
]
const DEPLOYS: Deployment[] = Array.from({ length: 30 }, (_, i) => {
  const failed = i % 9 === 4
  const created = STARTED - (i * 7 + 2) * 60_000
  const took = 40_000 + ((i * 7919) % 90_000)
  return {
    id: `dpl${i}`,
    project: { name: SERVICES[i % 3] as string },
    commit: {
      sha: ((0x3b9f1e7 * (i + 3)) % 0xfffffff).toString(16).padStart(7, "0"),
      message: MESSAGES[i % MESSAGES.length] as string,
      author: { name: "adriangalilea", avatar: "/adriangalilea.jpg" },
    },
    branch: "main",
    environment: i % 4 === 3 ? "preview" : "production",
    current: i < 3 && !failed,
    status: failed ? "error" : "ready",
    created,
    started: created + 3_000,
    duration: took,
    domains: [],
    build: { lines: [], spans: [] },
  }
})

/** A dashboard's row of panels, each as tall as the row, and the page never moves:
 *  one given its height, what it shows filling it and scrolling itself (a trace, a
 *  log); one capped at it, scrolling its rows under its pinned head. */
function Panels() {
  return (
    <div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
      <Request />
      <Deploys />
    </div>
  )
}

/** The request's trace and log, linked both ways by the page: a span chosen in the
 *  trace marks its lines in the log; a line named in the span's details opens the
 *  log on it; a line's span chip opens the trace on its span. */
function Request() {
  const [tab, setTab] = React.useState("trace")
  const [span, setSpan] = React.useState<string | null>(null)
  const [line, setLine] = React.useState<string | null>(null)
  const spanOf = (id: string) => LINES.find((l) => l.id === id)?.span?.id
  return (
    <section className="space-y-2.5">
      <h3 className="font-medium text-sm">POST /checkout</h3>
      <Tabs value={tab} onValueChange={(v) => setTab(v as string)}>
        <Panel
          height="28rem"
          bodyClassName="p-0"
          head={
            <TabsList>
              <TabsTab value="trace">trace</TabsTab>
              <TabsTab value="log">log</TabsTab>
            </TabsList>
          }
        >
          <TabsPanel value="trace">
            <Trace
              spans={SPANS}
              label="POST /checkout"
              visibleRows="fill"
              selected={span}
              onSelectedChange={setSpan}
              lines={LINES}
              line={line}
              onLineChange={(id) => {
                setLine(id)
                setTab("log")
              }}
            />
          </TabsPanel>
          <TabsPanel value="log">
            <Log
              lines={LINES}
              label="POST /checkout's log"
              selected={line}
              onSelectedChange={(id) => {
                setLine(id)
                if (id) setSpan(spanOf(id) ?? null)
              }}
              span={span}
              onSpanChange={(id) => {
                setSpan(id)
                setTab("trace")
              }}
            />
          </TabsPanel>
        </Panel>
      </Tabs>
    </section>
  )
}

/** The deploys capped at the row's height: past it the panel scrolls its rows under
 *  its head. The list draws no box of its own inside the panel, and its rows run to
 *  the panel's edges (no padding), their hairlines the panel's. */
function Deploys() {
  const failed = DEPLOYS.filter((d) => d.status === "error")
  const rows = (list: Deployment[]) => (
    <DeploymentList deployments={list} now={STARTED} filters={false} />
  )
  return (
    <section className="space-y-2.5">
      <h3 className="font-medium text-sm">deploys</h3>
      <Tabs defaultValue="all">
        <Panel
          maxHeight="28rem"
          bodyClassName="p-0"
          head={
            <TabsList>
              <TabsTab value="all">all</TabsTab>
              <TabsTab value="failed">
                failed
                <Badge variant="destructive" count={failed.length} />
              </TabsTab>
            </TabsList>
          }
        >
          <TabsPanel value="all">{rows(DEPLOYS)}</TabsPanel>
          <TabsPanel value="failed">{rows(failed)}</TabsPanel>
        </Panel>
      </Tabs>
    </section>
  )
}
// #endregion

// #region links
const VIEWS = ["files", "people", "places"] as const

// The URL is the state: each tab a link, the active one read back from the URL.
function LinkedViews({ view }: { view: string }) {
  return (
    <Tabs value={view}>
      <Panel
        glide
        head={
          <TabsList>
            {VIEWS.map((v) => (
              <TabsTab
                key={v}
                value={v}
                nativeButton={false}
                render={<Link href={{ query: { view: v } }} scroll={false} />}
              >
                {v}
              </TabsTab>
            ))}
          </TabsList>
        }
      >
        <TabsPanel value="files" className="text-[13px] opacity-75">
          151 files, 45 of them videos.
        </TabsPanel>
        <TabsPanel value="people" className="text-[13px] opacity-75">
          five people, each named once.
        </TabsPanel>
        <TabsPanel value="places" className="text-[13px] opacity-75">
          three disks, one of them offline.
        </TabsPanel>
      </Panel>
    </Tabs>
  )
}

function ViewFromUrl() {
  const asked = useSearchParams().get("view")
  return <LinkedViews view={VIEWS.find((v) => v === asked) ?? "files"} />
}
// #endregion

// #region places
const PLACES = {
  water: [
    ["lake.jpg", "the lake at dawn, before the wind"],
    ["river.jpg", "the river where it turns west"],
    ["shore.jpg", "the shore, low tide"],
  ],
  land: [
    ["peaks.jpg", "the peaks from the second camp"],
    ["field.jpg", "a field in late summer"],
    ["wide.jpg", "the valley, end to end"],
  ],
} as const

function Place({ src, line }: { src: string; line: string }) {
  return (
    <figure className="space-y-2">
      {/* biome-ignore lint/performance/noImgElement: a plain still in the scroller */}
      <img
        src={`/lightbox/${src}`}
        alt={line}
        className="aspect-[3/2] w-full rounded-lg object-cover"
      />
      <figcaption className="text-[13px] text-foreground/70">{line}</figcaption>
      <p className="text-[13px] leading-relaxed text-foreground/55">
        Walked in on the old track, three hours from the road. The light holds
        until late here, long enough to wait for the cloud to clear the ridge.
      </p>
    </figure>
  )
}

function Places() {
  return (
    <Tabs defaultValue="water" material="glass">
      <Panel
        // As tall as the window allows, less the page round it (the example's bar,
        // its padding and the picture's frame: about 8rem): a pane of photos fills
        // the view, as a fixed 30rem left half a tall window empty.
        maxHeight="calc(100dvh - 8rem)"
        action={<Badge>6 photos</Badge>}
        head={
          <TabsList>
            <TabsTab value="water">water</TabsTab>
            <TabsTab value="land">land</TabsTab>
          </TabsList>
        }
      >
        <TabsPanels>
          {Object.entries(PLACES).map(([name, places]) => (
            <TabsPanel key={name} value={name} className="space-y-6">
              {places.map(([src, line]) => (
                <Place key={src} src={src} line={line} />
              ))}
            </TabsPanel>
          ))}
        </TabsPanels>
      </Panel>
    </Tabs>
  )
}
// #endregion

// #region profile
/** One person's places, each with what can be seen of them there: facts read
 *  once and kept beside the page (github.demo.json, `mise unfurl`), nothing
 *  fetched but the pictures. */
const GITHUB = GITHUB_FACTS.profile as GithubProfile
const SITE: Unfurl = {
  url: "https://github.com/Lulzx/cuda-metal",
  site: "GitHub",
  title: "GitHub - Lulzx/cuda-metal",
  description: "CUDA compiler and runtime for Apple Silicon",
  image: "/xtldr-preview-cuda-metal.png",
}
const TELEGRAM: ProfileFacts = {
  platform: "telegram",
  url: "https://t.me/adriangalilea",
  handle: "adriangalilea",
  name: "Adrian Galilea",
  kind: "person",
  bio: "https://adriangalilea.com",
  photo: "/adriangalilea.jpg",
  counts: [],
}

function Profile() {
  return (
    <Tabs defaultValue="github" size="lg" labels="active">
      <Panel
        glide
        headHeight="3.5rem"
        head={
          <TabsList>
            <TabsTab value="github" icon={<CodeXml />}>
              github
            </TabsTab>
            <TabsTab value="website" icon={<Globe />}>
              website
            </TabsTab>
            <TabsTab value="youtube" icon={<Play />}>
              youtube
            </TabsTab>
            <TabsTab value="telegram" icon={<Send />}>
              telegram
            </TabsTab>
            <TabsTab value="email" icon={<AtSign />}>
              email
            </TabsTab>
          </TabsList>
        }
      >
        <TabsPanels>
          <TabsPanel value="github">
            <GithubProfileCard profile={GITHUB} />
          </TabsPanel>
          <TabsPanel value="website">
            <WebPreview facts={SITE} />
          </TabsPanel>
          <TabsPanel value="youtube">
            <YouTube video="https://youtu.be/aqz-KE-bpKQ" />
          </TabsPanel>
          <TabsPanel value="telegram">
            <SocialProfile facts={TELEGRAM} />
          </TabsPanel>
          <TabsPanel value="email" className="text-[15px] text-foreground/75">
            hello@adriangalilea.com
          </TabsPanel>
        </TabsPanels>
      </Panel>
    </Tabs>
  )
}
// #endregion

export default function TabsDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="segment"
        title="segment, plain and glass"
        description="the active tab lifted out of a well; the lift slides to the tab you choose."
        with="views"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Views />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6 text-white">
              <Views />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="line"
        title="line, plain and glass"
        description="a rule under the tabs, the active one's stretch of it lit."
        with="views"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Views variant="line" />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6 text-white">
              <Views variant="line" />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="icons"
        title="icons, the active one named"
        description="labels=&quot;active&quot;: the words open on the tab you choose as the lift slides to it, and close on the one you left."
        with="icon-tabs"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <IconTabs />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6 text-white">
              <IconTabs />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="sizes" glance title="the input's heights">
        <div className="flex flex-wrap items-start gap-6">
          {(["sm", "md", "lg"] as const).map((size) => (
            <Tabs key={size} size={size} defaultValue="files">
              <TabsList>
                <TabsTab value="files">files</TabsTab>
                <TabsTab value="people">people</TabsTab>
              </TabsList>
              <TabsPanel value="files" className="text-[13px] opacity-75">
                151 files
              </TabsPanel>
              <TabsPanel value="people" className="text-[13px] opacity-75">
                five people
              </TabsPanel>
            </Tabs>
          ))}
        </div>
      </Sample>
      <Sample
        name="kept"
        title="load once, then keep"
        description="a panel mounts the first time its tab is shown and stays: the note keeps what you typed, the report loads once, the live panel polls only while shown. archive mounts only while shown, and starts over each time."
        with="kept"
      >
        <div className="p-6">
          <Kept />
        </div>
      </Sample>
      <Sample
        name="dashboard"
        title="a box of tabs on a page"
        description="the tabs are a panel's head. Scroll the page: the page pins its header and the section's heading, the panel pins its head under them (pin, a CSS length the page passes) and the table its column names under the head (--panel-under); the rows pass under all of them."
        with="machines dashboard"
      >
        <div className="[--header-height:3rem]">
          <header className="sticky top-0 z-20 flex h-(--header-height) items-center border-border border-b bg-background px-4 font-medium text-[13px]">
            operations
          </header>
          <div className="p-4">
            <Fleet />
          </div>
        </div>
      </Sample>
      <Sample
        name="capped"
        title="boxes as tall as their row"
        description="a dashboard's row, and the page never moves. The request's panel is given its height (height): its trace and its log fill it and scroll themselves, drawing no box of their own inside it. The deploys are capped at it (maxHeight): past it the panel scrolls its rows under its head, each tab keeping its place."
        with="capped"
      >
        <div className="p-4">
          <Panels />
        </div>
      </Sample>
      <Sample
        name="glass"
        title="a box of glass"
        description="a glass panel over a picture, its head a strip of glass the photos pass under, seen through it, and its edge spilling what nears it."
        with="places"
      >
        <MaterialProvider material="glass">
          <div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
            <Places />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="profile"
        title="swiped between"
        description="a person's places, each panel what can be seen of them there. Swipe the panel sideways, with a finger or two on a trackpad: it leans with the hand, the next one beside it, and past a short line it goes there, one tab a swipe. Up and down still scrolls the page; the tabs, a click and the arrow keys are unchanged."
        with="profile"
      >
        <MaterialProvider material="glass">
          <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
            <div className="mx-auto max-w-xl">
              <Profile />
            </div>
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="links"
        title="tabs as links"
        description="each tab a link to ?view=, the active tab read from the URL: back and forward walk the tabs, a reload lands on the same one. Route segments work the same way, read with useSelectedLayoutSegment."
        with="links"
      >
        <div className="p-4">
          <React.Suspense fallback={<LinkedViews view="files" />}>
            <ViewFromUrl />
          </React.Suspense>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

tabs.tsx
"use client"

import { Tabs as BaseTabs } from "@base-ui/react/tabs"
import * as React from "react"
import { cn } from "@/lib/utils"
import { INTENT, wheelPx } from "@/registry/base-nova/lib/lightbox-motion"
import {
  phaseFeed,
  phaseStart,
} from "@/registry/base-nova/lib/lightbox-wheel-phase"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import {
  glassLift,
  glassVariants,
  schemeOf,
} from "@/registry/base-nova/ui/material-glass"
import { usePanelBody, usePanelHead } from "@/registry/base-nova/ui/panel"
import {
  createSlideTrack,
  SLIDE,
  SLIDE_TRACK,
} from "@/registry/base-nova/ui/slide-track"

// Views of one thing, one shown at a time (Base UI's tabs: the arrow keys move
// between tabs, Home and End to the ends, one Tab stop for the list; each panel is
// named by its tab). An indicator SLIDES to the active tab, so a change reads as one
// thing moving, never two states blinking.
//
//   <Tabs defaultValue="files">
//     <TabsList>
//       <TabsTab value="files">files</TabsTab>
//       <TabsTab value="people">people</TabsTab>
//     </TabsList>
//     <TabsPanel value="files">…</TabsPanel>
//     <TabsPanel value="people">…</TabsPanel>
//   </Tabs>
//
// Two looks: `segment` (the default), the tabs in a sunken well and the active one
// lifted out of it as toggle-group's chosen is; `line`, a rule under the tabs and the
// active one's stretch of it lit. Plain, or `material="glass"`: the well a bar of
// glass, the lift a lit pane, the line the glass's own lit edge.
//
// A panel mounts the first time its tab is shown and stays (`mount`, on TabsPanel);
// what inside it must pause while hidden asks `useTabsPanelActive()`. A tab can be a
// link (`render={<Link href="?view=files" />} nativeButton={false}`) with the
// active tab read back from the URL into `value`.
//
// In a box, the tabs own no container: the box is a `Panel`, the list its head.
//
//   <Tabs defaultValue="ops" material="glass">
//     <Panel head={<TabsList>…</TabsList>} action={<Button>refresh</Button>} maxHeight="30rem">
//       <TabsPanel value="ops">…</TabsPanel>
//     </Panel>
//   </Tabs>
//
// The list fits itself into the head (`usePanelHead`): a segment stands as far from
// the panel's side as from the strip's top, so its round end follows the corner; a
// line fills the strip, its active stretch on the strip's rule; plain, a line's
// chosen tab is cut from the body, a folder's tab. The panel pins the head, scrolls
// the body, glides it to a tab of another length (`glide`), and in a panel of a set
// height each tab's panel fills it and keeps its own place in the scroll.
//
// Panels in a track (`TabsPanels`) are swiped between: a finger, or two on a
// trackpad, sideways over them (the tab bar too), and the panel follows the hand,
// the next one beside it; past its halfway point it tips to that tab, one tab a
// gesture, the lightbox's pager rules (`lib/lightbox-motion`). Up and down stays the page's; a press, a
// click and the keys are unchanged. A panel in a track also stays laid out while
// hidden, so showing it again is paint alone.
//
//   <TabsPanels>
//     <TabsPanel value="files">…</TabsPanel>
//     <TabsPanel value="people">…</TabsPanel>
//   </TabsPanels>

export type TabsSize = "sm" | "md" | "lg"
export type TabsVariant = "segment" | "line"
/** Which tabs show their words: all of them, or only the active one, the rest
 *  their icon alone (a row of many places that stays short). */
export type TabsLabels = "all" | "active"

const LIST: Record<TabsSize, string> = {
  sm: "text-[12px]",
  md: "text-[13px]",
  lg: "text-sm",
}
/** A tab in a segment's well, at the input's heights less the well's padding. */
const SEGMENT_TAB: Record<TabsSize, string> = {
  sm: "h-6 px-2.5",
  md: "h-7 px-3",
  lg: "h-9 px-4",
}
/** The glass segment is a pill, its radius half its height (a tab and the well's
 *  2 px either side); the chosen tab's lit pane likewise, half a tab's. */
const GLASS_LIST: Record<TabsSize, string> = {
  sm: "[--glass-radius:14px]",
  md: "[--glass-radius:16px]",
  lg: "[--glass-radius:20px]",
}
const GLASS_LIFT: Record<TabsSize, string> = {
  sm: "[--glass-radius:12px]",
  md: "[--glass-radius:14px]",
  lg: "[--glass-radius:18px]",
}
/** The one motion of a change of tab: the indicator's slide, and with
 *  `labels="active"` the words opening on the new tab and closing on the old over
 *  the same time and curve, so the three read as one movement. Base UI re-measures
 *  the indicator whenever a tab's size changes (a ResizeObserver per tab), so it
 *  follows a tab still opening. */
const GLIDE =
  "duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"

const LINE_TAB: Record<TabsSize, string> = {
  sm: "h-7 px-2",
  md: "h-8 px-2.5",
  lg: "h-10 px-3",
}

/** A segment list in a panel's head (px-5, `--panel-head` tall) stands as far from
 *  the panel's side as from the strip's top: the strip's height less the well's (28,
 *  32, 40), halved, less the head's own inset. So the well sits in the panel's
 *  corner and its round end follows the corner's; at the head's 20 px it stood
 *  twice as far from the side as from the top, off the corner (Adrian, 2026-10-12). */
const SEGMENT_IN_HEAD: Record<TabsSize, string> = {
  sm: "-ml-[calc(1.25rem-(var(--panel-head)-28px)/2)]",
  md: "-ml-[calc(1.25rem-(var(--panel-head)-32px)/2)]",
  lg: "-ml-[calc(1.25rem-(var(--panel-head)-40px)/2)]",
}

/** A line list in a head fills the strip, its own rule dropped (the strip's edge is
 *  the rule), its tabs as tall as the strip; pulled left by its first tab's padding
 *  so that label starts where the body does. */
const LINE_IN_HEAD = cn(
  "self-stretch items-stretch after:hidden [&>[data-slot=tabs-tab]]:h-auto",
)
const LINE_PULL: Record<TabsSize, string> = {
  sm: "-ml-2",
  md: "-ml-2.5",
  lg: "-ml-3",
}

/** A folder list's start (plain line in a head): its first label where the body's
 *  text starts (20 px) less a tab's padding (8, 10, 12) and its edge (1), so the
 *  chosen tab's edge stands well in from the panel's. Pulled like a line list,
 *  that edge stood a sliver from the panel's border (Adrian, 2026-10-12). Its tabs
 *  stand from a little below the strip's top, told by their shape alone (no lit
 *  stretch of rule). */
const FOLDER_IN_HEAD: Record<TabsSize, string> = {
  sm: "-ml-[9px]",
  md: "-ml-[11px]",
  lg: "-ml-[13px]",
}
const FOLDER = "pt-2 [&>[data-slot=tabs-indicator]]:hidden"

/** The line look in a plain head: the chosen tab is cut from the body, a folder's
 *  tab. It wears the body's colour as it is seen, solid (the card over the page's
 *  ground) and a hairline round its top, and stands a pixel over the head's rule, so
 *  it opens into what it shows; the rest sit in the head. Every tab keeps the edge,
 *  transparent, so choosing one moves nothing. Read by the tabs inside a list marked
 *  `data-folder`. */
const FOLDER_TAB = cn(
  "in-data-folder:rounded-t-lg in-data-folder:border in-data-folder:border-transparent in-data-folder:border-b-0",
  "in-data-folder:data-active:relative in-data-folder:data-active:top-px in-data-folder:data-active:border-border",
  "in-data-folder:data-active:bg-background in-data-folder:data-active:bg-[linear-gradient(var(--color-card),var(--color-card))]",
)

/** The tabs' size and variant, for the list inside (material and scheme ride the
 *  MaterialProvider the tabs set). */
const Look = React.createContext<{
  size: TabsSize
  variant: TabsVariant
  labels: TabsLabels
}>({ size: "md", variant: "segment", labels: "all" })

export type TabsProps = Omit<
  React.ComponentProps<typeof BaseTabs.Root>,
  "className"
> & {
  size?: TabsSize
  variant?: TabsVariant
  /** `active`: every tab its icon, the active one its words too, opening as the
   *  indicator slides to it. Every tab then needs an `icon`. Default `all`. */
  labels?: TabsLabels
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

export function Tabs({
  size = "md",
  variant = "segment",
  labels = "all",
  material,
  scheme,
  className,
  ref,
  ...props
}: TabsProps) {
  const m = useMaterial(material)
  // The root draws no surface: its words wear the scheme of what it stands on, and
  // what is inside it asks the same question itself. Over a picture the list (the
  // surface) is read by the part of it under the list, a control in a panel by the
  // part under that control.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  const look = React.useMemo(
    () => ({ size, variant, labels }),
    [size, variant, labels],
  )
  return (
    <MaterialProvider material={m} scheme={scheme}>
      <Look.Provider value={look}>
        <BaseTabs.Root
          ref={here}
          data-slot="tabs"
          data-material={m}
          data-variant={variant}
          className={cn(
            "flex gap-3 data-[orientation=horizontal]:flex-col",
            schemeOf(s),
            className,
          )}
          {...props}
        />
      </Look.Provider>
    </MaterialProvider>
  )
}

export type TabsListProps = Omit<
  React.ComponentProps<typeof BaseTabs.List>,
  "className"
> & { className?: string }

/** Where the indicator comes to REST under `labels="active"`, in the list's own
 *  coordinates: the active tab's box once every other tab has closed and it has
 *  opened. Base UI's indicator measures where the tab IS, and while the words open
 *  that is a moving target: each frame restarted its glide toward a box still
 *  growing, so it chased, landed late and crept the last pixels, the words cut where
 *  the lift ended. This is the box it will be: a tab closed is its width less its
 *  words' track and the gap that track takes (the words' negative margin gives the
 *  gap back when closed, so the sum holds at any point of the motion), a tab open is
 *  that plus the gap and its words' own width (`scrollWidth`, whole even inside a
 *  closed track). The tabs come first in the list; anything after them (an add
 *  button) moves nothing before the indicator. */
function restingBox(list: HTMLElement): Box | null {
  const tabs = Array.from(
    list.querySelectorAll<HTMLElement>(":scope > [data-slot=tabs-tab]"),
  )
  const active = tabs.findIndex((tab) => tab.hasAttribute("data-active"))
  if (active === -1) return null
  const widths = tabs.map((tab) => {
    const words = tab.querySelector<HTMLElement>(
      ":scope > [data-slot=tabs-tab-words]",
    )
    const inner = words?.firstElementChild
    if (!words || !(inner instanceof HTMLElement))
      throw new Error(
        'TabsList: a tab under labels="active" has no words track',
      )
    const gap = Number.parseFloat(getComputedStyle(tab).columnGap) || 0
    const margin = Number.parseFloat(getComputedStyle(words).marginLeft) || 0
    const closed =
      tab.getBoundingClientRect().width -
      words.getBoundingClientRect().width -
      gap -
      margin
    return { closed, open: closed + gap + inner.scrollWidth }
  })
  const between = Number.parseFloat(getComputedStyle(list).columnGap) || 0
  const first = tabs[0]
  const tab = tabs[active]
  const width = widths[active]
  if (!first || !tab || !width)
    throw new Error("TabsList: the active tab vanished")
  const left = widths
    .slice(0, active)
    .reduce((x, w) => x + w.closed + between, first.offsetLeft)
  return {
    left,
    top: tab.offsetTop,
    width: width.open,
    height: tab.offsetHeight,
  }
}

type Box = { left: number; top: number; width: number; height: number }

const sameBox = (a: Box | null, b: Box | null) =>
  a === b ||
  (a !== null &&
    b !== null &&
    Math.abs(a.left - b.left) < 0.5 &&
    Math.abs(a.width - b.width) < 0.5 &&
    a.top === b.top &&
    a.height === b.height)

/** The indicator's resting box under `labels="active"`, kept current: when a tab
 *  becomes active, and when the list changes size (a font arriving, the words
 *  moving: re-reading mid-motion gives the same box, so it settles at once).
 *  `moving` turns the glide on after the first placement, so the lift appears where
 *  it belongs instead of sliding in from the corner. */
function useRestingBox(list: HTMLElement | null, on: boolean) {
  const [box, setBox] = React.useState<Box | null>(null)
  const [moving, setMoving] = React.useState(false)
  React.useLayoutEffect(() => {
    if (!on || !list) return
    const place = () => {
      const next = restingBox(list)
      setBox((was) => (sameBox(was, next) ? was : next))
    }
    place()
    const frame = requestAnimationFrame(() => setMoving(true))
    const actives = new MutationObserver(place)
    actives.observe(list, {
      subtree: true,
      attributes: true,
      attributeFilter: ["data-active"],
    })
    const sizes = new ResizeObserver(place)
    sizes.observe(list)
    return () => {
      cancelAnimationFrame(frame)
      actives.disconnect()
      sizes.disconnect()
    }
  }, [list, on])
  return { box, moving }
}

/** The tabs, and the indicator that slides between them. In a panel's head it fits
 *  the strip (`usePanelHead`). */
export function TabsList({
  className,
  children,
  ref,
  ...props
}: TabsListProps) {
  const m = useMaterial()
  const [s, here] = useSurfaceScheme<HTMLDivElement>(undefined, ref)
  const { size, variant, labels } = React.useContext(Look)
  const inHead = usePanelHead() !== null
  const glass = m === "glass"
  const folded = labels === "active"
  // Plain, in a head, the line look is a folder's tabs (FOLDER_TAB).
  const folder = inHead && !glass && variant === "line"
  const [list, setList] = React.useState<HTMLDivElement | null>(null)
  const both = React.useCallback(
    (node: HTMLDivElement | null) => {
      setList(node)
      here(node)
    },
    [here],
  )
  const resting = useRestingBox(list, folded)
  const indicator = cn(
    "pointer-events-none absolute -z-10",
    (!folded || resting.moving) && [
      "transition-[translate,width,height]",
      GLIDE,
    ],
    variant === "segment"
      ? [
          "top-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) translate-y-(--active-tab-top)",
          glass
            ? [glassLift, GLASS_LIFT[size]]
            : "rounded-md bg-background shadow-[0_1px_2px_rgb(0_0_0/0.15)] ring-1 ring-foreground/8 scheme-dark:bg-foreground/12",
        ]
      : [
          "bottom-0 left-0 h-0.5 w-(--active-tab-width) translate-x-(--active-tab-left) rounded-full",
          glass
            ? "bg-current shadow-[0_0_8px_color-mix(in_oklab,currentColor_60%,transparent)]"
            : "bg-foreground",
        ],
  )
  return (
    <BaseTabs.List
      ref={both}
      data-slot="tabs-list"
      data-folder={folder ? "" : undefined}
      className={cn(
        "relative z-0 flex w-fit items-center",
        LIST[size],
        schemeOf(s),
        variant === "segment"
          ? [
              "gap-0.5 p-0.5",
              glass
                ? [glassVariants({ shape: "surface" }), GLASS_LIST[size]]
                : "rounded-lg bg-foreground/[0.06]",
            ]
          : [
              "gap-1",
              // The rule under the tabs: the page's hairline, or glass's lit line.
              "after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-px after:content-['']",
              glass ? "after:bg-current/15" : "after:bg-foreground/10",
            ],
        inHead && [
          "min-w-0",
          variant === "segment"
            ? SEGMENT_IN_HEAD[size]
            : [
                LINE_IN_HEAD,
                folder ? [FOLDER_IN_HEAD[size], FOLDER] : LINE_PULL[size],
              ],
        ],
        className,
      )}
      {...props}
    >
      {children}
      {folded ? (
        // Its own indicator, on the same variables Base UI's sets, placed at the
        // resting box (`restingBox`); none until it is measured.
        resting.box && (
          <span
            aria-hidden
            data-slot="tabs-indicator"
            className={indicator}
            style={
              {
                "--active-tab-left": `${resting.box.left}px`,
                "--active-tab-top": `${resting.box.top}px`,
                "--active-tab-width": `${resting.box.width}px`,
                "--active-tab-height": `${resting.box.height}px`,
              } as React.CSSProperties
            }
          />
        )
      ) : (
        <BaseTabs.Indicator data-slot="tabs-indicator" className={indicator} />
      )}
    </BaseTabs.List>
  )
}

export type TabsTabProps = Omit<
  React.ComponentProps<typeof BaseTabs.Tab>,
  "className"
> & {
  /** Drawn before the words; required under `labels="active"`, where it is all an
   *  inactive tab shows. */
  icon?: React.ReactNode
  className?: string
}

/** The words of a tab under `labels="active"`: a one-column grid whose track opens
 *  from 0fr to 1fr on the active tab (the width of the words, whatever they are,
 *  with no measuring), the words fading in with it. Closed, a negative margin takes
 *  back the tab's gap so an icon alone sits centred. Still in the tab's text while
 *  closed, so the tab keeps its accessible name. */
const FOLDED_WORDS = cn(
  "grid grid-cols-[0fr] -ml-1.5 opacity-0 transition-[grid-template-columns,margin,opacity]",
  GLIDE,
  "group-data-active/tab:ml-0 group-data-active/tab:grid-cols-[1fr] group-data-active/tab:opacity-100",
)

export function TabsTab({ icon, className, children, ...props }: TabsTabProps) {
  const m = useMaterial()
  const { size, variant, labels } = React.useContext(Look)
  const glass = m === "glass"
  if (labels === "active" && icon === undefined)
    throw new Error(
      'TabsTab: labels="active" shows an inactive tab as its icon alone, and this tab has no icon',
    )
  return (
    <BaseTabs.Tab
      data-slot="tabs-tab"
      className={cn(
        "group/tab inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 font-medium whitespace-nowrap transition-[color,opacity] duration-200 select-none data-disabled:cursor-not-allowed data-disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        variant === "segment"
          ? [SEGMENT_TAB[size], glass ? "rounded-full" : "rounded-md"]
          : [LINE_TAB[size], FOLDER_TAB],
        glass
          ? "opacity-60 hover:opacity-90 data-active:opacity-100"
          : "text-foreground/55 hover:text-foreground/85 data-active:text-foreground",
        className,
      )}
      {...props}
    >
      {icon}
      {labels === "active" ? (
        <span data-slot="tabs-tab-words" className={FOLDED_WORDS}>
          <span className="overflow-hidden">{children}</span>
        </span>
      ) : (
        children
      )}
    </BaseTabs.Tab>
  )
}

/** When a panel's content is mounted. `seen`: the first time it is shown (its tab
 *  chosen, or a hand in a track starting to swipe it in), and from then on, hidden
 *  while another tab is shown (its state, its scroll and what was typed in it
 *  survive a switch; a panel never shown never mounts, so what it fetches on mount
 *  it fetches on first show, once). `active`: only while shown, so it starts over
 *  each time (in a track it cannot be drawn beside: a swipe towards it leans into
 *  nothing, and it arrives once chosen). `always`: from the start, shown or not
 *  (what must be in the page unseen). */
export type TabsPanelMount = "seen" | "active" | "always"

export type TabsPanelProps = Omit<
  React.ComponentProps<typeof BaseTabs.Panel>,
  "className" | "keepMounted" | "render"
> & {
  /** When its content is mounted (`TabsPanelMount`): default `seen`. */
  mount?: TabsPanelMount
  className?: string
}

/** Whether the panel round it is the one shown; null outside a panel. */
const PanelActive = React.createContext<boolean | null>(null)

/** Whether the panel this is inside is the one shown. For content that stays
 *  mounted while hidden (`mount="seen"`, the default) and must stop working
 *  meanwhile: a poll, a subscription, a clock. Throws outside a `TabsPanel`. */
export function useTabsPanelActive(): boolean {
  const active = React.useContext(PanelActive)
  if (active === null)
    throw new Error("useTabsPanelActive: not inside a TabsPanel")
  return active
}

/** Holds its content back until it is first shown, then keeps it. React's
 *  state-from-render: the latch flips in the render that first sees it shown,
 *  before anything commits, so the content mounts in that same commit. */
function Seen({
  active,
  children,
}: {
  active: boolean
  children: React.ReactNode
}) {
  const [seen, setSeen] = React.useState(active)
  if (active && !seen) setSeen(true)
  return seen || active ? children : null
}

/** A tab's place in the scroll of the panel round the tabs, when that panel
 *  scrolls (`Panel` `maxHeight`, `height`): the tabs share its one scroller, so
 *  each remembers where it was while shown and is put back there when shown again
 *  (a tab never shown starts at the top). Restored before paint, so a switch never
 *  shows the old place first. The place is returned: a track draws a panel beside
 *  the shown one at it, so a swipe brings it in where it will stand. */
function useKeptScroll(active: boolean): React.RefObject<number> {
  const body = usePanelBody()
  const kept = React.useRef(0)
  React.useLayoutEffect(() => {
    const el = body?.scroller.current
    if (!active || !el) return
    el.scrollTop = kept.current
    const keep = () => {
      kept.current = el.scrollTop
    }
    el.addEventListener("scroll", keep, { passive: true })
    return () => el.removeEventListener("scroll", keep)
  }, [active, body])
  return kept
}

/** What one tab shows. Base UI's panel either mounts only while shown or from the
 *  start (`keepMounted`); `seen` is the third way, built here: Base keeps the
 *  panel's element, `Seen` holds its content back until the first show. Hidden
 *  is the `hidden` attribute, which wins over a `flex` or `grid` on the panel
 *  (Tailwind's preflight makes it important); in a track it is `AWAY` instead. In a
 *  panel of a set height (`Panel` `height`) it fills the body, and what it holds
 *  fills it: a log scrolls itself, never scrolled twice. */
export function TabsPanel({
  mount = "seen",
  className,
  children,
  ...props
}: TabsPanelProps) {
  const filled = usePanelBody()?.filled === true
  // In a track every panel keeps its element, its place on the track; what
  // `mount` holds back is its content (PanelFace).
  const inTrack = React.useContext(InTrack) !== null
  return (
    <BaseTabs.Panel
      data-slot="tabs-panel"
      className={cn(
        "rounded-md",
        filled && "flex flex-1 flex-col [&>*]:min-h-0 [&>*]:flex-1",
        className,
      )}
      {...props}
      keepMounted={mount !== "active" || inTrack}
      render={(attrs, state) => (
        // Shown: not hidden, and not on its way out (Base keeps a panel visible
        // while its exit runs, `ending`).
        <PanelFace
          attrs={attrs}
          active={!state.hidden && state.transitionStatus !== "ending"}
          from={state.tabActivationDirection}
          mount={mount}
        >
          {children}
        </PanelFace>
      )}
    />
  )
}

/** A panel's arrival: when it becomes the one shown (never on the page's first
 *  paint, where it simply is), it comes in from the side the indicator travelled
 *  from, 16 px, and up from nothing, over 220 ms on the indicator's curve: a tab
 *  to the right brings its panel in from the right, so the content moves the way
 *  the lift did and the two read as one gesture (Base UI's
 *  `tabActivationDirection`; vertical tabs move it vertically, `none` only fades).
 *  Short on purpose: it confirms the switch, it does not perform it. Played with
 *  the Web Animations API on the panel's own element, so it needs no stylesheet
 *  and leaves no style behind; none under reduced motion. The panel that leaves is
 *  hidden at once: two panels crossing in one place would read as both. A panel a
 *  track brought in by a swipe arrives by the swipe, and plays none of this. */
type TabsTabActivationDirection = BaseTabs.Tab.ActivationDirection
const TRAVEL = 16
const FROM: Record<TabsTabActivationDirection, string> = {
  right: `translateX(${TRAVEL}px)`,
  left: `translateX(${-TRAVEL}px)`,
  down: `translateY(${TRAVEL}px)`,
  up: `translateY(${-TRAVEL}px)`,
  none: "none",
}
const ARRIVE_TIMING: KeyframeAnimationOptions = {
  duration: 220,
  easing: "cubic-bezier(0.22, 1, 0.36, 1)",
}

/** A panel in a track (`TabsPanels`) is a slide (ui/slide-track): always laid out,
 *  side by side with the others, the track scrolled to the one shown, so showing
 *  one is paint alone (`display: none` threw its style and layout away and every
 *  show built it again). One not shown is `inert`, no key or reader reaching what
 *  is off the track, and outside the track's clip, so it is never painted. Never
 *  `content-visibility: auto`: a panel it skipped measured nothing, the track took
 *  that height, and every panel was clipped to none. */
const SLIDE_PANEL = cn(SLIDE, "pr-(--slide-pad-r) pl-(--slide-pad-l)")

function PanelFace({
  attrs,
  active,
  from,
  mount,
  children,
}: {
  attrs: React.ComponentProps<"div">
  active: boolean
  /** The way the active tab moved, Base UI's `tabActivationDirection`. */
  from: TabsTabActivationDirection
  mount: TabsPanelMount
  children: React.ReactNode
}) {
  const { ref, hidden, className, ...rest } = attrs
  const track = React.useContext(InTrack)
  const id = rest.id
  // In a track, the panels either side of where it is heading hold their content
  // too, so a swipe brings in a panel that is already there.
  const near = track !== null && id !== undefined && track.near.has(id)
  const own = React.useRef<HTMLDivElement | null>(null)
  const was = React.useRef(active)
  useKeptScroll(active)
  const both = React.useCallback(
    (node: HTMLDivElement | null) => {
      own.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref],
  )
  React.useLayoutEffect(() => {
    if (!track) return
    if (own.current?.parentElement?.dataset.slot !== "tabs-panels")
      throw new Error(
        "TabsPanel: inside TabsPanels it stands straight in the track, a slide of it",
      )
  }, [track])
  React.useLayoutEffect(() => {
    const arrived = active && !was.current
    was.current = active
    const node = own.current
    // In a track the track brings it in: no arrival of its own.
    if (!arrived || !node || track) return
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
    node.animate(
      [
        { opacity: 0, transform: FROM[from] },
        { opacity: 1, transform: "none" },
      ],
      ARRIVE_TIMING,
    )
  }, [active, from, track])
  return (
    <div
      ref={both}
      {...rest}
      hidden={track ? undefined : hidden}
      inert={track !== null && !active}
      className={cn(className, track && SLIDE_PANEL)}
    >
      <PanelActive.Provider value={active}>
        {mount === "seen" ? (
          <Seen active={active || near}>{children}</Seen>
        ) : mount === "active" && track ? (
          active && children
        ) : (
          children
        )}
      </PanelActive.Provider>
    </div>
  )
}

// ── the track: swiping between the panels ──

/** What a track tells the panels in it: which hold their content, the shown one's
 *  neighbours where the track is heading, so a swipe brings in a panel that is
 *  already there. */
type Track = { near: ReadonlySet<string> }
const InTrack = React.createContext<Track | null>(null)
const NONE: ReadonlySet<string> = new Set()

/** A wheel stream is sideways once its travel leans this much more sideways than
 *  upright. Up and down is the page's verb, so sideways must win clearly; the
 *  lightbox, a stage where sideways is the verb, weighs it the other way round
 *  (`wheelAxisOf`). */
const SIDEWAYS = 1.5
/** A touch that swiped is no tap: the click the browser may send after it, within
 *  this long, is dropped, or a swipe begun on a link would follow the link. */
const CLICK_AFTER_SWIPE = 400

/** Does something under the hand take sideways motion itself (a scroller that
 *  scrolls sideways, a slider, anything that asks for every touch)? Then the
 *  gesture is its, read once as it starts. */
function claimed(target: EventTarget | null, track: HTMLElement): boolean {
  for (
    let n = target instanceof Element ? target : null;
    n && n !== track;
    n = n.parentElement
  ) {
    if (n.matches("[role=slider], input[type=range]")) return true
    const s = getComputedStyle(n)
    if (s.touchAction === "none" || s.touchAction === "pan-x") return true
    if (
      (s.overflowX === "auto" || s.overflowX === "scroll") &&
      n.scrollWidth > n.clientWidth + 1
    )
      return true
  }
  return false
}

/** THE TRACK, BOUND: the panels are the lightbox's slide track (ui/slide-track),
 *  the one engine both swipe on, so a panel follows, tips one a gesture, holds
 *  at the first and the last, and glides from where it stands exactly as a picture
 *  does. This reads the hand into it and keeps it and the tabs one thing:
 *
 *  - Choosing is pressing the tab (`tab.click()`), at the moment the swipe buys
 *    it: Base decides, a link navigates, a controlled `value` asks its owner, as
 *    under a tap, and the tab strip moves with the decision.
 *  - A tab chosen any other way (pressed, a key, a link) glides the track there on
 *    the same curve, from where it stands.
 *  - The track is as tall as the panel it is heading for (the others are clipped
 *    by it), so a short panel next to a tall one is not given the tall one's room.
 *  - A wheel: one axis for the whole stream, the lightbox's phase (`phaseFeed`);
 *    only a sideways one is the track's, up and down stays the page's.
 *  - A finger (or a pen): the track is `touch-action: pan-y`, the browser pans up
 *    and down and pinches; a touch that starts sideways arrives here whole. A
 *    mouse drag stays a text selection.
 *
 *  Each panel is one tab's, paired by the tab's `aria-controls`. With a sink
 *  (`TabsPanels` `onTrace`), every decision here and in the engine is one line:
 *  `tabs …` the binding's (an axis read, a tab chosen, a height), `track …` the
 *  engine's. Returns the unbinding. */
function bindTrack(
  el: HTMLElement,
  filled: boolean,
  showNear: (ids: ReadonlySet<string>) => void,
  sink: () => ((line: string) => void) | undefined,
): () => void {
  const tell = (line: string) => sink()?.(line)
  const root = el.closest<HTMLElement>("[data-slot=tabs]")
  if (!root) throw new Error("TabsPanels: outside Tabs, there are no tabs")
  const list = Array.from(
    root.querySelectorAll<HTMLElement>("[role=tablist]"),
  ).find((l) => l.closest("[data-slot=tabs]") === root)
  if (!list) throw new Error("TabsPanels: these tabs have no TabsList")
  const panels = () => Array.from(el.children) as HTMLElement[]
  /** A slide's tab: the one that names its panel (`aria-controls`, Base's). */
  const tabOf = (panel: HTMLElement | undefined): HTMLElement => {
    const tab = panel?.id
      ? list.querySelector<HTMLElement>(
          `[role=tab][aria-controls="${CSS.escape(panel.id)}"]`,
        )
      : null
    if (!tab)
      throw new Error(
        `TabsPanels: no tab names the panel ${panel?.id ?? "(none)"}; every panel in the track is one tab's`,
      )
    return tab
  }
  const selected = (t: HTMLElement) =>
    t.getAttribute("aria-selected") === "true"
  const current = () =>
    Math.max(
      0,
      panels().findIndex((p) => selected(tabOf(p))),
    )
  const choose = (i: number) => {
    const tab = tabOf(panels()[i])
    if (selected(tab)) return
    tell(`tabs choose ${i} (${tab.textContent?.trim()})`)
    tab.click()
  }
  const near = (i: number) =>
    showNear(
      new Set(
        panels()
          .slice(Math.max(0, i - 1), i + 2)
          .map((p) => p.id)
          .filter(Boolean),
      ),
    )
  const upright = () => root.getAttribute("data-orientation") === "vertical"
  const reduced = () =>
    window.matchMedia("(prefers-reduced-motion: reduce)").matches

  /** Straight in a panel's padded body (`data-panel-pad`, through any
   *  `display: contents` wrapper), the track reaches over the body's padding to the
   *  panel's own edge and each slide puts it back inside itself
   *  (`--slide-pad-l`/`-r`): the panels run to the edge as they do scrolling, never
   *  clipped in a line inside the frame, and a swipe there is the track's. The
   *  padding is read off the body, the one place it is true (a body is `p-5`, `p-4`
   *  or `p-0` by its `bodyClassName`). Before the track measures its pitch. */
  const bleed = () => {
    let frame = el.parentElement
    while (frame && getComputedStyle(frame).display === "contents")
      frame = frame.parentElement
    if (!frame?.hasAttribute("data-panel-pad")) return
    const pad = getComputedStyle(frame)
    const l = pad.paddingLeft
    const r = pad.paddingRight
    if (
      el.style.getPropertyValue("--slide-pad-l") === l &&
      el.style.getPropertyValue("--slide-pad-r") === r
    )
      return
    tell(`tabs bleed over the panel's padding (${l} ${r})`)
    el.style.marginLeft = `calc(-1 * ${l})`
    el.style.marginRight = `calc(-1 * ${r})`
    el.style.setProperty("--slide-pad-l", l)
    el.style.setProperty("--slide-pad-r", r)
  }
  bleed()

  const track = createSlideTrack({
    el,
    count: () => panels().length,
    current,
    reduced,
    onAim: (i) => {
      if (i === null) return
      near(i)
      choose(i)
      fit()
    },
    onArrive: choose,
    trace: tell,
    report: tell,
    debug: () => sink() !== undefined,
  })

  /** The track's height, the panel it is heading for. */
  const fit = () => {
    if (filled) return
    const i = track.aim() ?? current()
    const p = panels()[i]
    if (!p) return
    const h = `${p.offsetHeight}px`
    if (el.style.height === h) return
    tell(`tabs height ${h} (panel ${i}, was ${el.style.height || "unset"})`)
    el.style.height = h
  }
  // A panel's height decides the track's; the track's width decides the slides'
  // pitch. Apart, so setting the height is never read back as a resize; and the
  // height is set in the next frame, never inside the observer's round, where it
  // resized what other observers above it watch (a glide's) in the same round.
  let fitting = 0
  const sizes = new ResizeObserver(() => {
    if (!fitting)
      fitting = requestAnimationFrame(() => {
        fitting = 0
        fit()
      })
  })
  for (const p of panels()) sizes.observe(p)
  let wide = el.clientWidth
  const width = new ResizeObserver(() => {
    if (el.clientWidth === wide) return
    bleed()
    wide = el.clientWidth
    track.measure()
    if (!track.busy()) track.jumpTo(current())
  })
  width.observe(el)

  // A tab chosen any way: the track goes there, unless it is going there already
  // (a swipe bought it).
  const follow = () => {
    const i = current()
    fit()
    if (track.aim() === i) return
    tell(
      `tabs tab ${i} chosen elsewhere (aim ${track.aim() ?? "-"}): glide there`,
    )
    track.glideTo(i)
  }
  const chosen = new MutationObserver(follow)
  chosen.observe(list, {
    subtree: true,
    attributes: true,
    attributeFilter: ["aria-selected"],
  })
  fit()
  track.jumpTo(current())
  /** A swipe starts: the panels either side of the shown one hold their content
   *  from now on (`seen`), so what it brings in is there. Not before: a panel
   *  never swiped to nor chosen never mounts, so it fetches nothing. */
  const swiping = () => near(track.aim() ?? current())

  let phase = phaseStart()
  let axis: "x" | "y" | "pass" | null = null
  /** A stream opened (the phase's `start`: silence, or a hand cutting a coast
   *  short) and the track has not been fed since: the events that read its axis
   *  are held back, and the track must still hear that a hand is back. */
  let handBack = false
  /** A wheel: one axis for the stream, read off its travel (its first event
   *  carries whatever the fingers happened to do). Undecided, a sideways event is
   *  held back from the page (which would scroll it, or go back in history on a
   *  Mac) and an upright one passes, so scrolling the page never waits. */
  const onWheel = (e: WheelEvent) => {
    if (e.ctrlKey) return
    // Heard on the whole tabs (the tab bar too: a sideways swipe over the tabs is
    // as much a swipe between them as one over a panel), but never a nested
    // tabs' own: theirs is theirs.
    const at = e.target instanceof Element ? e.target : null
    if (at?.closest("[data-slot=tabs]") !== root) return
    const dx = wheelPx(e.deltaX, e.deltaMode, window.innerHeight)
    const dy = wheelPx(e.deltaY, e.deltaMode, window.innerHeight)
    const now = performance.now()
    const fed = phaseFeed(phase, { dx, dy, t: now })
    phase = fed.phase
    if (fed.read.start) {
      const why = upright()
        ? "upright tabs"
        : claimed(e.target, el.contains(at) ? el : root)
          ? "claimed by what is under the hand"
          : null
      axis = why ? "pass" : null
      handBack = true
      tell(
        `tabs wheel stream opens (${fed.read.why})${why ? `: passes, ${why}` : ""}`,
      )
    }
    if (axis === "pass" || axis === "y") return
    if (axis === null) {
      const m = fed.read.movement
      if (Math.max(Math.abs(m.x), Math.abs(m.y)) < INTENT) {
        if (Math.abs(dx) > Math.abs(dy)) e.preventDefault()
        return
      }
      axis = Math.abs(m.x) > SIDEWAYS * Math.abs(m.y) ? "x" : "y"
      tell(
        `tabs wheel axis ${axis} (moved x ${Math.round(m.x)}, y ${Math.round(m.y)})`,
      )
      if (axis === "y") return
      swiping()
    }
    e.preventDefault()
    track.wheel(dx, handBack, fed.read.momentum)
    handBack = false
  }

  let touch: {
    id: number
    x0: number
    y0: number
    last: number
    axis: "x" | null
  } | null = null
  let quietUntil = 0
  const onDown = (e: PointerEvent) => {
    if (e.pointerType === "mouse") return
    // A second finger is a pinch, the browser's.
    if (!e.isPrimary) {
      tell("tabs a second finger: a pinch, the browser's")
      if (touch?.axis === "x") track.release()
      touch = null
      return
    }
    if (upright() || claimed(e.target, el)) {
      tell(
        `tabs ${e.pointerType} down: passes (${upright() ? "upright tabs" : "claimed by what is under it"})`,
      )
      return
    }
    tell(`tabs ${e.pointerType} down`)
    touch = {
      id: e.pointerId,
      x0: e.clientX,
      y0: e.clientY,
      last: e.clientX,
      axis: null,
    }
  }
  const onMove = (e: PointerEvent) => {
    if (!touch || e.pointerId !== touch.id) return
    if (touch.axis === null) {
      const tx = e.clientX - touch.x0
      const ty = e.clientY - touch.y0
      if (Math.abs(tx) + Math.abs(ty) < INTENT) return
      // Upright: the browser is panning the page and will cancel this pointer.
      if (Math.abs(tx) <= Math.abs(ty)) {
        tell(
          `tabs finger axis y (x ${Math.round(tx)}, y ${Math.round(ty)}): the page's`,
        )
        touch = null
        return
      }
      tell(`tabs finger axis x (x ${Math.round(tx)}, y ${Math.round(ty)})`)
      touch.axis = "x"
      touch.last = e.clientX
      swiping()
      // The finger moves right, the panel comes from the left: the travel is
      // its negative.
      track.finger(-tx)
      return
    }
    track.finger(-(e.clientX - touch.last))
    touch.last = e.clientX
  }
  const onUp = (e: PointerEvent) => {
    if (!touch || e.pointerId !== touch.id) return
    const swiped = touch.axis === "x"
    touch = null
    tell(
      `tabs finger ${e.type === "pointerup" ? "up" : "CANCELLED by the browser"}`,
    )
    if (!swiped) return
    if (e.type === "pointerup") quietUntil = e.timeStamp + CLICK_AFTER_SWIPE
    // A finger says when it let go: the swipe ends on the event.
    track.release()
  }
  const onClick = (e: MouseEvent) => {
    if (e.timeStamp > quietUntil) return
    e.preventDefault()
    e.stopPropagation()
  }
  const onSettle = () => track.settle()

  root.addEventListener("wheel", onWheel, { passive: false })
  el.addEventListener("pointerdown", onDown)
  el.addEventListener("pointermove", onMove)
  el.addEventListener("pointerup", onUp)
  el.addEventListener("pointercancel", onUp)
  el.addEventListener("click", onClick, { capture: true })
  el.addEventListener("scrollend", onSettle)
  return () => {
    root.removeEventListener("wheel", onWheel)
    el.removeEventListener("pointerdown", onDown)
    el.removeEventListener("pointermove", onMove)
    el.removeEventListener("pointerup", onUp)
    el.removeEventListener("pointercancel", onUp)
    el.removeEventListener("click", onClick, { capture: true })
    el.removeEventListener("scrollend", onSettle)
    chosen.disconnect()
    sizes.disconnect()
    cancelAnimationFrame(fitting)
    width.disconnect()
    track.dispose()
  }
}

export type TabsPanelsProps = Omit<React.ComponentProps<"div">, "className"> & {
  className?: string
  /** Where the track's trace goes, one line a decision (`bindTrack`): set, it is
   *  recorded; unset, nothing is. For a page's debug flag. */
  onTrace?: (line: string) => void
}

/** The panels' track: the `TabsPanel`s straight inside it, and nothing else, one
 *  for each tab, in the tabs' order. A hand swipes between them on the lightbox's
 *  track (`bindTrack`); every panel stays laid out beside the others. In a panel
 *  of a set height it fills the body, as the panels in it do. */
export function TabsPanels({
  className,
  onTrace,
  children,
  ref,
  ...props
}: TabsPanelsProps) {
  const filled = usePanelBody()?.filled === true
  // Read by the binding at each decision, so turning the trace on or off never
  // rebinds the track under a hand.
  const sink = React.useRef(onTrace)
  React.useLayoutEffect(() => {
    sink.current = onTrace
  }, [onTrace])
  const [near, setNear] = React.useState(NONE)
  const [el, setEl] = React.useState<HTMLDivElement | null>(null)
  const both = React.useCallback(
    (node: HTMLDivElement | null) => {
      setEl(node)
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref],
  )
  React.useLayoutEffect(() => {
    if (!el) return
    return bindTrack(el, filled, setNear, () => sink.current)
  }, [el, filled])
  const track = React.useMemo(() => ({ near }), [near])
  return (
    <InTrack.Provider value={track}>
      <div
        ref={both}
        data-slot="tabs-panels"
        // Room between two panels (`gap`), so their edges never touch mid-swipe;
        // the engine reads it off the track. In a panel it reaches to the
        // panel's edge (`bleed`, in the binding).
        className={cn(
          SLIDE_TRACK,
          "touch-pan-y touch-pinch-zoom gap-6",
          filled ? "min-h-0 flex-1" : "items-start",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </InTrack.Provider>
  )
}