# Tabs (/docs/tabs)





`Tabs` is a navigation control: a row (or column) of triggers, each revealing its own content panel, with a pill that slides to the active trigger. Visually it's [`SegmentGroup`](/docs/segment-group)'s twin — the same `filled` / `light` / `outline` / `subtle` / `default` variant × color system and the same sliding-pill mechanics — but built for real content panels instead of a single-choice value, with no [`Field`](/docs/field) integration (Tabs is navigation, not a form control).

```tsx
import { Tabs } from '@42-central/ui-react/tabs';

<Tabs
  defaultValue="overview"
  data={[
    { value: 'overview', label: 'Overview', content: <Overview /> },
    { value: 'activity', label: 'Activity', content: <Activity /> },
  ]}
/>
```

## Playground [#playground]

<story.Playground />

## Items [#items]

`data` is a flat array of `{ value, label, content, disabled? }` — each tab needs its own `content`, so (unlike [`SegmentGroup`](/docs/segment-group#items)) there's no bare-string shorthand.

```tsx
<Tabs
  defaultValue="overview"
  data={[
    { value: 'overview', label: 'Overview', content: <Overview /> },
    { value: 'activity', label: 'Activity', content: <Activity /> },
    { value: 'settings', label: 'Settings', content: <Settings />, disabled: true },
  ]}
/>
```

Each item also accepts an optional `testId`, defaulting to `` `tab-${value}` `` — the matching
content panel gets `` `${testId}-panel` ``. See the [Testing](/docs/testing) guide. The compound
`Tabs.Trigger` / `Tabs.Content` / `Tabs.List` / `Tabs.Root` / `Tabs.Indicator` parts each take
their own `testId` prop directly, for the same reason.

## Variants [#variants]

Five treatments, mirroring [`Button`](/docs/button) and [`SegmentGroup`](/docs/segment-group). The variant restyles the tray, every trigger, and the sliding pill off the same `--c-*` color slots. `filled` is a solid pill, `light` a soft tint, `outline` a ringed pill over the tray, `subtle` is chromeless. `default` is the same solid pill as `filled` on a lighter tray — with no `color` set it resolves to black (white in dark theme), and it tints like every other variant when you give it one.

<Preview align="center" code="&#x22;<Tabs variant=\&#x22;filled\&#x22; color=\&#x22;brand\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;light\&#x22; color=\&#x22;brand\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;outline\&#x22; color=\&#x22;brand\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;subtle\&#x22; color=\&#x22;brand\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;default\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />&#x22;">
  <div className="flex flex-col items-start gap-3">
    <Tabs variant="filled" color="brand" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="light" color="brand" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="outline" color="brand" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="subtle" color="brand" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="default" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />
  </div>
</Preview>

## Sizes [#sizes]

Five sizes — `xs` through `xl` — matching the rest of the family. Sizing lives on the triggers; the tray's own gap scales alongside.

<Preview align="center" code="&#x22;<Tabs size=\&#x22;xs\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs size=\&#x22;sm\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs size=\&#x22;md\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs size=\&#x22;lg\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs size=\&#x22;xl\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />&#x22;">
  <div className="flex flex-col items-center gap-3">
    <Tabs size="xs" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs size="sm" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs size="md" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs size="lg" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs size="xl" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />
  </div>
</Preview>

## Colors [#colors]

The resting triggers are palette-independent; only the sliding pill (and the active label's text) adopt `color`. Set it once and every kit palette tints for free — the active label follows the pill onto `--c-on-solid`, so it stays legible whichever palette you pick. `color` is scoped to the tab strip: it does not tint whatever your panels render, so a `Button` inside a panel keeps its own palette.

<Preview align="center" code="&#x22;<Tabs variant=\&#x22;filled\&#x22; color=\&#x22;brand\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;filled\&#x22; color=\&#x22;green\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;filled\&#x22; color=\&#x22;blue\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;filled\&#x22; color=\&#x22;purple\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />\n<Tabs variant=\&#x22;default\&#x22; color=\&#x22;teal\&#x22; defaultValue=\&#x22;a\&#x22; data={[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]} />&#x22;">
  <div className="flex flex-col items-start gap-3">
    <Tabs variant="filled" color="brand" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="filled" color="green" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="filled" color="blue" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="filled" color="purple" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />

    <Tabs variant="default" color="teal" defaultValue="a" data="[{ value: 'a', label: 'Overview', content: 'A' }, { value: 'b', label: 'Activity', content: 'B' }]" />
  </div>
</Preview>

## Orientation [#orientation]

Set `orientation="vertical"` to stack the triggers in a column instead of a row.

<story.Preview variant="Vertical" />

## Disabled tab [#disabled-tab]

A single tab can be disabled via `{ value, label, content, disabled: true }`. It's skipped by keyboard navigation and can't be clicked.

<story.Preview variant="Disabled tab" />

## Compound API [#compound-api]

Drop to the composable parts for anything the flat `data` array can't express — custom trigger content, interleaved non-tab elements, or driving the value from your own state. `variant`, `size` and `color` all live on `Tabs.List` and apply to the tray, every trigger, and the indicator. `color` is a `data-color` attribute on the list specifically — putting it on `Tabs.Root` would also cover every `Tabs.Content`, leaking the palette into your panel content.

```tsx
'use client';
import { Tabs } from '@42-central/ui-react/tabs';

<Tabs.Root defaultValue="overview">
  <Tabs.List variant="filled" color="brand">
    <Tabs.Indicator />
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="activity">Activity</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview"><Overview /></Tabs.Content>
  <Tabs.Content value="activity"><Activity /></Tabs.Content>
</Tabs.Root>
```

## Controlled [#controlled]

Drive the value from state. `onChange` hands you the selected value directly (the data-driven form); the compound `Tabs.Root` takes Ark's own `value` / `onValueChange`.

```tsx
'use client';
import { useState } from 'react';
import { Tabs } from '@42-central/ui-react/tabs';

export function ProjectTabs() {
  const [tab, setTab] = useState('overview');
  return (
    <Tabs
      value={tab}
      onChange={setTab}
      data={[
        { value: 'overview', label: 'Overview', content: <Overview /> },
        { value: 'activity', label: 'Activity', content: <Activity /> },
      ]}
    />
  );
}
```

## API [#api]

<TypeTable
  type="{
  data: {
    type: '{ value, label, content, disabled? }[]',
    description: 'Tabs, in order. Each needs its own content — there is no bare-string shorthand.',
  },
  value: {
    type: 'string | null',
    description: 'Controlled active value.',
  },
  defaultValue: {
    type: 'string | null',
    default: 'data[0]?.value',
    description: 'Uncontrolled initial value. Defaults to the first item when omitted.',
  },
  onChange: {
    type: '(value: string) => void',
    description: 'Fires with the newly active value (narrowed to the data value union).',
  },
  variant: {
    type: '&#x22;filled&#x22; | &#x22;light&#x22; | &#x22;outline&#x22; | &#x22;subtle&#x22; | &#x22;default&#x22;',
    default: '&#x22;default&#x22;',
    description: 'Visual treatment, mirroring Button/SegmentGroup. Styles the tray, triggers, and pill.',
  },
  size: {
    type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;',
    default: '&#x22;md&#x22;',
    description: 'Trigger sizing; the tray gap scales alongside.',
  },
  color: {
    type: 'Color',
    description:
      'Pill fill + active-trigger text accent. Scoped to the tab strip (it lands on Tabs.List), so panel content keeps its own palette. On the compound API, set it on Tabs.List.',
  },
  orientation: {
    type: '&#x22;horizontal&#x22; | &#x22;vertical&#x22;',
    default: '&#x22;horizontal&#x22;',
    description: 'Lay the triggers in a row or a column.',
  },
  activationMode: {
    type: '&#x22;automatic&#x22; | &#x22;manual&#x22;',
    default: '&#x22;automatic&#x22;',
    description: 'automatic activates a tab on arrow-key focus; manual requires Enter/click.',
  },
  loopFocus: {
    type: 'boolean',
    default: 'true',
    description: 'Whether arrow-key navigation loops from the last trigger back to the first.',
  },
  testId: {
    type: 'string',
    description: 'data-testid on the root. See the per-item testId field above for the data-driven items themselves.',
  },
}"
/>

## Accessibility [#accessibility]

* Built on Ark UI's Tabs machine: `role="tablist"` / `"tab"` / `"tabpanel"`, with `aria-selected` and roving `tabIndex` on the triggers and `aria-controls` / `aria-labelledby` linking each trigger to its panel.
* `←` / `→` (or `↑` / `↓` when `orientation="vertical"`) move focus between triggers; `activationMode="automatic"` (the default) activates the focused tab immediately, `"manual"` waits for `Enter` / a click. `Tab` enters and leaves the tablist as a single stop.
* A `disabled` trigger is skipped by arrow-key navigation and unclickable.
* RTL is honored via the document `dir`.
