# Tabs

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/tabs/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

Several views of one thing in one place, exactly one of them visible (also called a tab bar). Reach for tabs when the views are alternatives the user switches between, not content to compare side by side.

```ts
import { Tabs, TabList, Tab, TabPanel } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/tabs/

Only the visible panel stands in the document; a hidden panel keeps no focus, scroll position or live region.

Uncontrolled, the tabs keep the choice themselves. Controlled, they remember nothing, so the visible tab can come from the address.

## Examples

### One view at a time

Give each `Tab` and its `TabPanel` the same `value`; `defaultValue` picks the first, and the tabs keep the choice.

```tsx
import { Tab, TabList, TabPanel, Tabs, Text } from "@umriss-ui/core";

export default function OneViewAtATime() {
  return (
    <Tabs defaultValue="overview">
      <TabList aria-label="Checkout">
        <Tab value="overview">Overview</Tab>
        <Tab value="incidents">Incidents</Tab>
        <Tab value="on-call">On call</Tab>
      </TabList>
      <TabPanel value="overview">
        <Text size="sm" tone="secondary">
          Payments team · tier 1 · p95 objective 300 ms · 99.95 % a month.
        </Text>
      </TabPanel>
      <TabPanel value="incidents">
        <Text size="sm" tone="secondary">
          INC-1048, SEV1, open since 09:42: card payments time out.
        </Text>
      </TabPanel>
      <TabPanel value="on-call">
        <Text size="sm" tone="secondary">
          Primary Jonas Keller, secondary Ada Mwangi, until tomorrow 09:00.
        </Text>
      </TabPanel>
    </Tabs>
  );
}
```

### Keep the tab in the address

Control `value` with `onChange` when the visible tab belongs in the address, so a link or a reload opens the same view.

```tsx
import { useState } from "react";
import { Stack, Tab, TabList, TabPanel, Tabs, Text } from "@umriss-ui/core";

export default function KeepTheTabInTheAddress() {
  const [tab, setTab] = useState("stops");

  return (
    <Stack gap={3}>
      <Tabs value={tab} onChange={setTab}>
        <TabList aria-label="Tour T-01">
          <Tab value="stops">Stops</Tab>
          <Tab value="vehicle">Vehicle</Tab>
          <Tab value="costs">Costs</Tab>
        </TabList>
        <TabPanel value="stops">
          <Text size="sm" tone="secondary">
            11 stops, two of them outside their window.
          </Text>
        </TabPanel>
        <TabPanel value="vehicle">
          <Text size="sm" tone="secondary">
            FP 214 K, a van with 1,200 kg payload, from the North depot.
          </Text>
        </TabPanel>
        <TabPanel value="costs">
          <Text size="sm" tone="secondary">
            86 km and 6 h 40 min of driving time.
          </Text>
        </TabPanel>
      </Tabs>
      <Text size="xs" tone="muted">
        Address: <code>#/tours/T-01/{tab}</code>
      </Text>
    </Stack>
  );
}
```

### Loading and empty panels

A panel still loading shows a `Skeleton`, an empty one an `EmptyState`; the tab itself stays and says what would be there.

```tsx
import { EmptyState, Skeleton, Stack, Tab, TabList, TabPanel, Tabs, Text } from "@umriss-ui/core";

export default function LoadingAndEmptyPanels() {
  return (
    <Tabs defaultValue="work">
      <TabList aria-label="Hana Sato">
        <Tab value="work">Work</Tab>
        <Tab value="leave">Leave</Tab>
        <Tab value="profile">Profile</Tab>
      </TabList>
      <TabPanel value="work">
        <Stack gap={2} style={{ maxWidth: 320 }}>
          <Skeleton />
          <Skeleton width="80%" />
          <Skeleton width="60%" />
        </Stack>
      </TabPanel>
      <TabPanel value="leave">
        <EmptyState title="No leave planned" description="Holidays, sick days and training appear here once they are entered." />
      </TabPanel>
      <TabPanel value="profile">
        <Text size="sm" tone="secondary">
          Developer in the Apps team, 40 h a week.
        </Text>
      </TabPanel>
    </Tabs>
  );
}
```

### An invoice in tabs

Tabs split one record into views of different shape; a count in the tab's label says what waits behind it.

```tsx
import { Badge, Card, CardBody, CardHeader, Grid, Stack, Stepper, Tab, TabList, TabPanel, Tabs, Text } from "@umriss-ui/core";

/* Data from the controlling world, written out here so the example runs on its own. */
const on = (month: number, day: number) => new Date(2026, month - 1, day).getTime();

interface InvoiceLine {
  description: string;
  quantity: number;
  unitPrice: number;
  /** A fraction: 0.1 is ten per cent off. */
  discount: number;
  /** A fraction: 0.19 or 0.07, or 0 where no VAT is charged. */
  vatRate: number;
}

interface Invoice {
  id: string;
  supplier: string;
  costCentre: string;
  received: number;
  due: number;
  status: "awaiting approval" | "approved" | "paid" | "rejected";
  lines: readonly InvoiceLine[];
}

const INVOICES: readonly Invoice[] = [
  {
    id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "CC-4400", received: on(3, 16), due: on(4, 15), status: "awaiting approval",
    lines: [
      { description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9, discount: 0.1, vatRate: 0.19 },
      { description: "Printer paper, box of 5 reams", quantity: 40, unitPrice: 21.5, discount: 0, vatRate: 0.19 },
      { description: "Delivery", quantity: 1, unitPrice: 35, discount: 0, vatRate: 0.19 },
    ],
  },
  {
    id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "CC-1200", received: on(3, 13), due: on(4, 12), status: "awaiting approval",
    lines: [
      { description: "Trade fair stand, 3 days", quantity: 1, unitPrice: 12_400, discount: 0.05, vatRate: 0.19 },
      { description: "Catering, per guest", quantity: 180, unitPrice: 18.5, discount: 0, vatRate: 0.07 },
    ],
  },
  {
    id: "INV-26-0309", supplier: "Nimbrel Software", costCentre: "CC-4300", received: on(3, 9), due: on(4, 8), status: "approved",
    lines: [
      { description: "Design tool licences, annual", quantity: 12, unitPrice: 540, discount: 0.15, vatRate: 0.19 },
      { description: "Onboarding session", quantity: 2, unitPrice: 890, discount: 0, vatRate: 0.19 },
    ],
  },
  {
    id: "INV-26-0302", supplier: "Fenwright Legal", costCentre: "CC-4100", received: on(3, 2), due: on(3, 16), status: "paid",
    lines: [{ description: "Contract review, hours", quantity: 14.5, unitPrice: 260, discount: 0, vatRate: 0.19 }],
  },
  {
    id: "INV-26-0226", supplier: "Corrin Travel", costCentre: "CC-1100", received: on(2, 26), due: on(3, 12), status: "paid",
    lines: [
      { description: "Rail tickets, sales conference", quantity: 22, unitPrice: 139, discount: 0, vatRate: 0.07 },
      { description: "Hotel, nights", quantity: 44, unitPrice: 112, discount: 0.08, vatRate: 0.07 },
    ],
  },
  {
    id: "INV-26-0221", supplier: "Stellbrook Consulting", costCentre: "CC-2100", received: on(2, 21), due: on(3, 23), status: "rejected",
    lines: [{ description: "Architecture review, days", quantity: 6, unitPrice: 1450, discount: 0, vatRate: 0 }],
  },
];

interface Approval {
  invoice: string;
  step: "cost centre" | "finance";
  approver: string;
  decision: "approved" | "rejected" | "pending";
  at?: number;
  comment?: string;
}

/** Two signatures an invoice needs: its cost centre's owner, then finance. */
const APPROVALS: readonly Approval[] = [
  { invoice: "INV-26-0318", step: "cost centre", approver: "Olga Ivanova", decision: "pending" },
  { invoice: "INV-26-0317", step: "cost centre", approver: "Rafael Ortiz", decision: "approved", at: on(3, 16) },
  { invoice: "INV-26-0317", step: "finance", approver: "Martina Vogel", decision: "pending" },
  { invoice: "INV-26-0309", step: "cost centre", approver: "Kenji Arai", decision: "approved", at: on(3, 10) },
  { invoice: "INV-26-0309", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 11) },
  { invoice: "INV-26-0302", step: "cost centre", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
  { invoice: "INV-26-0302", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
  { invoice: "INV-26-0226", step: "cost centre", approver: "Helen Marsh", decision: "approved", at: on(2, 27) },
  { invoice: "INV-26-0226", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 2) },
  { invoice: "INV-26-0221", step: "cost centre", approver: "Anika Sørensen", decision: "rejected", at: on(2, 24), comment: "Not ordered - the review was cancelled in January." },
];

const INVOICE = INVOICES.find((invoice) => invoice.id === "INV-26-0317")!;
const SIGNATURES = APPROVALS.filter((approval) => approval.invoice === INVOICE.id);
const euro = (amount: number) => amount.toLocaleString("en", { style: "currency", currency: "EUR" });
const net = (line: (typeof INVOICE.lines)[number]) => line.quantity * line.unitPrice * (1 - line.discount);
const day = (time: number) => new Date(time).toLocaleDateString("en-GB", { day: "numeric", month: "long" });

export default function AnInvoiceInTabs() {
  const pending = SIGNATURES.findIndex((approval) => approval.decision === "pending");

  return (
    <Card style={{ maxWidth: 640 }}>
      <CardHeader
        eyebrow={`Invoice ${INVOICE.id}`}
        title={INVOICE.supplier}
        actions={<Badge tone="warning">Awaiting approval</Badge>}
      />
      <CardBody>
        <Tabs defaultValue="lines">
          <TabList aria-label={`Invoice ${INVOICE.id}`}>
            <Tab value="lines">Lines ({INVOICE.lines.length})</Tab>
            <Tab value="approvals">Approvals ({SIGNATURES.length})</Tab>
            <Tab value="payment">Payment</Tab>
          </TabList>
          <TabPanel value="lines">
            <Stack gap={2} style={{ paddingTop: 12 }}>
              {INVOICE.lines.map((line) => (
                <Grid key={line.description} columns={3} gap={3}>
                  <Text size="sm">{line.description}</Text>
                  <Text size="sm" tone="secondary">
                    {line.quantity} × {euro(line.unitPrice)}
                    {line.discount > 0 ? `, ${line.discount * 100} % off` : ""}
                  </Text>
                  <Text size="sm" style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
                    {euro(net(line))}
                  </Text>
                </Grid>
              ))}
            </Stack>
          </TabPanel>
          <TabPanel value="approvals">
            <Stepper
              aria-label="Approvals"
              orientation="vertical"
              style={{ paddingTop: 12 }}
              steps={SIGNATURES.map((approval) => ({
                label: `${approval.step === "finance" ? "Finance" : "Cost centre"}: ${approval.approver}`,
                description: approval.at === undefined ? "Waiting" : `${approval.decision}, ${day(approval.at)}`,
                failed: approval.decision === "rejected",
              }))}
              current={pending === -1 ? SIGNATURES.length : pending}
            />
          </TabPanel>
          <TabPanel value="payment">
            <Text size="sm" tone="secondary" style={{ paddingTop: 12 }}>
              Received {day(INVOICE.received)}, due {day(INVOICE.due)}, from cost centre {INVOICE.costCentre}.
            </Text>
          </TabPanel>
        </Tabs>
      </CardBody>
    </Card>
  );
}
```

## When to use something else

- Sections a reader may open several of at once → Accordion
- Two views the user needs side by side → Splitter

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Moves onto the visible tab, then on into its panel. |
| `Arrow Right / Arrow Left` | Shows the next or previous tab, wrapping at the ends. |
| `Home / End` | Shows the first or the last tab. |

## API

### `TabsProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `defaultValue` | `string` | — | Uncontrolled: the tab that stands first. Afterwards the choice belongs to the tabs - for a switch no address and no handler needs to know.<br>Shown in: [One view at a time](https://romanhaendler.github.io/umriss-ui/core/tabs/#one-view-at-a-time), [Loading and empty panels](https://romanhaendler.github.io/umriss-ui/core/tabs/#loading-and-empty-panels), [An invoice in tabs](https://romanhaendler.github.io/umriss-ui/core/tabs/#an-invoice-in-tabs) and 1 more |
| `value` | `string` | — | The value of the visible tab. Controlled, `Tabs` remember nothing, so that the tab can come out of an address.<br>Shown in: [Keep the tab in the address](https://romanhaendler.github.io/umriss-ui/core/tabs/#keep-the-tab-in-the-address) |
| `ref` | `Ref<HTMLDivElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onChange` | `(value: string) => void` | — | Receives the value of the chosen tab. Controlled, the tab switches only once `value` follows; uncontrolled it is merely a message.<br>Shown in: [Keep the tab in the address](https://romanhaendler.github.io/umriss-ui/core/tabs/#keep-the-tab-in-the-address) |

Also takes every attribute of `<div>` – without `defaultValue`, `onChange`.

### `TabProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `string` | — | The value this tab reports – and under which its panel stands.<br>Shown in: [One view at a time](https://romanhaendler.github.io/umriss-ui/core/tabs/#one-view-at-a-time), [Keep the tab in the address](https://romanhaendler.github.io/umriss-ui/core/tabs/#keep-the-tab-in-the-address), [Loading and empty panels](https://romanhaendler.github.io/umriss-ui/core/tabs/#loading-and-empty-panels) and 2 more |
| `ref` | `Ref<HTMLButtonElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

Also takes every attribute of `<button>`.

### `TabPanelProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `string` | — | The value of the tab this panel belongs to. Only the matching panel stands in the document – a hidden panel would keep focus, scroll position and live regions that nobody sees.<br>Shown in: [One view at a time](https://romanhaendler.github.io/umriss-ui/core/tabs/#one-view-at-a-time), [Keep the tab in the address](https://romanhaendler.github.io/umriss-ui/core/tabs/#keep-the-tab-in-the-address), [Loading and empty panels](https://romanhaendler.github.io/umriss-ui/core/tabs/#loading-and-empty-panels) and 2 more |
| `ref` | `Ref<HTMLDivElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

Also takes every attribute of `<div>`.

## Known limits

- No scrolling or overflow menu for more tabs than fit, and no closing or reordering of tabs: keep the list short and fixed.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
