# Tabs

Status: stable. Added in Components 2.5.0. Updated in Components 2.16.0, 20 Oct 2026.

Tabbed navigation between related views with an animated active indicator.

## Import

```tsx
import { Tabs } from '@oztix/roadie-components/tabs'
```

## Examples

### Default

```tsx
<Tabs defaultValue='overview'>
  <Tabs.List>
    <Tabs.Tab value='overview'>Overview</Tabs.Tab>
    <Tabs.Tab value='details'>Details</Tabs.Tab>
    <Tabs.Tab value='history'>History</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
  <Tabs.Panel value='overview'>
    <p className='py-4 text-subtle'>Overview panel content.</p>
  </Tabs.Panel>
  <Tabs.Panel value='details'>
    <p className='py-4 text-subtle'>Details panel content.</p>
  </Tabs.Panel>
  <Tabs.Panel value='history'>
    <p className='py-4 text-subtle'>History panel content.</p>
  </Tabs.Panel>
</Tabs>
```

### Emphasis

`emphasis` controls the visual treatment of the list and the indicator. The active indicator animates between tabs in every variant. Only the appearance of the indicator changes.

#### strong

A high-contrast segmented control with a solid inverted pill behind the active tab. Use when tabs need to stand out against a busy or photographic background.

```tsx
<Tabs defaultValue='week' emphasis='strong'>
  <Tabs.List>
    <Tabs.Tab value='day'>Day</Tabs.Tab>
    <Tabs.Tab value='week'>Week</Tabs.Tab>
    <Tabs.Tab value='month'>Month</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

#### normal (default)

A segmented control with a raised pill that slides between active tabs. Use inside cards and toolbars.

```tsx
<Tabs defaultValue='week' emphasis='normal'>
  <Tabs.List>
    <Tabs.Tab value='day'>Day</Tabs.Tab>
    <Tabs.Tab value='week'>Week</Tabs.Tab>
    <Tabs.Tab value='month'>Month</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

#### subtle

A ghost segmented control. It has no track, just a tinted pill that slides behind the active tab. Use when the surrounding surface already provides framing.

```tsx
<Tabs defaultValue='week' emphasis='subtle'>
  <Tabs.List>
    <Tabs.Tab value='day'>Day</Tabs.Tab>
    <Tabs.Tab value='week'>Week</Tabs.Tab>
    <Tabs.Tab value='month'>Month</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

#### subtler

A flat row with a sliding underline. Use for in-page section navigation where tabs should defer to the surrounding content. Under forced colours the underline takes the system highlight colour.

```tsx
<Tabs defaultValue='week' emphasis='subtler'>
  <Tabs.List>
    <Tabs.Tab value='day'>Day</Tabs.Tab>
    <Tabs.Tab value='week'>Week</Tabs.Tab>
    <Tabs.Tab value='month'>Month</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

### Sizes

```tsx
<Tabs defaultValue='week' size='sm'>
  <Tabs.List>
    <Tabs.Tab value='day'>Day</Tabs.Tab>
    <Tabs.Tab value='week'>Week</Tabs.Tab>
    <Tabs.Tab value='month'>Month</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='week' size='md'>
  <Tabs.List>
    <Tabs.Tab value='day'>Day</Tabs.Tab>
    <Tabs.Tab value='week'>Week</Tabs.Tab>
    <Tabs.Tab value='month'>Month</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='week' size='lg'>
  <Tabs.List>
    <Tabs.Tab value='day'>Day</Tabs.Tab>
    <Tabs.Tab value='week'>Week</Tabs.Tab>
    <Tabs.Tab value='month'>Month</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

### Icon only

A tab that holds only an icon becomes a circle at every size and emphasis, and the indicator follows its shape. With `subtler`, the underline runs the width of the circle. Give each one an `aria-label`, because the icon has no text for assistive tech to read. Roadie warns in development when a name is missing.

```tsx
<Tabs defaultValue='tickets' emphasis='strong'>
  <Tabs.List aria-label='Event views'>
    <Tabs.Tab value='tickets' aria-label='Tickets'>
      <TicketIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='attendees' aria-label='Attendees'>
      <UsersIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='payouts' aria-label='Payouts'>
      <WalletIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='tickets' emphasis='normal'>
  <Tabs.List aria-label='Event views'>
    <Tabs.Tab value='tickets' aria-label='Tickets'>
      <TicketIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='attendees' aria-label='Attendees'>
      <UsersIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='payouts' aria-label='Payouts'>
      <WalletIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='tickets' emphasis='subtle'>
  <Tabs.List aria-label='Event views'>
    <Tabs.Tab value='tickets' aria-label='Tickets'>
      <TicketIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='attendees' aria-label='Attendees'>
      <UsersIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='payouts' aria-label='Payouts'>
      <WalletIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='tickets' emphasis='subtler'>
  <Tabs.List aria-label='Event views'>
    <Tabs.Tab value='tickets' aria-label='Tickets'>
      <TicketIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='attendees' aria-label='Attendees'>
      <UsersIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Tab value='payouts' aria-label='Payouts'>
      <WalletIcon weight='bold' className='size-4' />
    </Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

### Intents

`intent` cascades down to descendants via CSS custom properties. The active indicator picks up the intent's strong colour in `subtler` emphasis.

```tsx
<Tabs defaultValue='one' intent='accent' emphasis='subtle'>
  <Tabs.List>
    <Tabs.Tab value='one'>Accent</Tabs.Tab>
    <Tabs.Tab value='two'>Two</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='one' intent='brand' emphasis='subtle'>
  <Tabs.List>
    <Tabs.Tab value='one'>Brand</Tabs.Tab>
    <Tabs.Tab value='two'>Two</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='one' intent='success' emphasis='subtle'>
  <Tabs.List>
    <Tabs.Tab value='one'>Success</Tabs.Tab>
    <Tabs.Tab value='two'>Two</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
<Tabs defaultValue='one' intent='danger' emphasis='subtle'>
  <Tabs.List>
    <Tabs.Tab value='one'>Danger</Tabs.Tab>
    <Tabs.Tab value='two'>Two</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

### States

Disabled tabs are skipped during keyboard navigation and ignore clicks.

```tsx
<Tabs defaultValue='active'>
  <Tabs.List>
    <Tabs.Tab value='active'>Active</Tabs.Tab>
    <Tabs.Tab value='disabled' disabled>Disabled</Tabs.Tab>
    <Tabs.Tab value='another'>Another</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

### Composition

#### With icons

Use the Base UI `render` prop to access the active state and switch a Phosphor icon's `weight` between `bold` and `fill`.

```tsx
<Tabs defaultValue='profile' emphasis='normal'>
  <Tabs.List>
    <Tabs.Tab
      value='profile'
      render={(props, state) => (
        <button {...props}>
          <Heart className='size-4' weight={state.active ? 'fill' : 'bold'} />
          Profile
        </button>
      )}
    />
    <Tabs.Tab
      value='settings'
      render={(props, state) => (
        <button {...props}>
          <Gear className='size-4' weight={state.active ? 'fill' : 'bold'} />
          Settings
        </button>
      )}
    />
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

#### Vertical direction

Pass `direction='vertical'` to render a column of left-aligned tabs. With `emphasis='subtler'` the active indicator becomes a 2px bar on the left edge that slides between tabs; the pill emphases (`strong`, `normal`, `subtle`) keep their full-rect shape around the active tab in vertical mode.

```tsx
<Tabs defaultValue='one' direction='vertical' emphasis='subtler'>
  <div className='flex gap-6'>
    <Tabs.List>
      <Tabs.Tab value='one'>Account</Tabs.Tab>
      <Tabs.Tab value='two'>Notifications</Tabs.Tab>
      <Tabs.Tab value='three'>Privacy</Tabs.Tab>
      <Tabs.Indicator />
    </Tabs.List>
    <div className='flex-1'>
      <Tabs.Panel value='one'>
        <p className='text-subtle'>Account settings.</p>
      </Tabs.Panel>
      <Tabs.Panel value='two'>
        <p className='text-subtle'>Notification preferences.</p>
      </Tabs.Panel>
      <Tabs.Panel value='three'>
        <p className='text-subtle'>Privacy controls.</p>
      </Tabs.Panel>
    </div>
  </div>
</Tabs>
```

#### When space runs out

A horizontal list never wraps. When the tabs don't fit, the list scrolls sideways, and it scrolls the active tab into view.

```tsx
<Tabs defaultValue='history' className='max-w-64'>
  <Tabs.List>
    <Tabs.Tab value='overview'>Overview</Tabs.Tab>
    <Tabs.Tab value='sales'>Sales</Tabs.Tab>
    <Tabs.Tab value='attendees'>Attendees</Tabs.Tab>
    <Tabs.Tab value='history'>History</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

#### Persistent panels

By default, panels mount only when active. Pass `keepMounted` on `Tabs.Panel` to preserve in-flight state (forms, scroll position, video playback) when switching tabs.

```tsx
<Tabs.Panel value='form' keepMounted>
  <Field>
    <Field.Label>Name</Field.Label>
    <Field.Input />
  </Field>
</Tabs.Panel>
```

## Guidelines

- **Use Tabs for related views**, not for top-level page navigation. Reach for `Breadcrumb` or a sidebar instead when tabs would otherwise represent separate pages.
- **Prefer `emphasis='normal'` inside cards or toolbars.** The segmented track gives the control its own visual frame. Reach for `emphasis='strong'` when tabs need to stand out against a busy or photographic background, `emphasis='subtle'` for a quieter ghost-segmented look, and `emphasis='subtler'` for a flat underline-only row that defers to the surrounding content.
- **Place the `Tabs.Indicator` inside `Tabs.List`** as a sibling of the tabs themselves, not inside a tab. The indicator is positioned absolutely against the list and reads its geometry from Base UI's `--active-tab-*` CSS variables.

### Tabs switch panels; a toggle group sets a value

Use Tabs when each choice shows its own content below. When the choice changes how the same content is filtered or shown, such as a date range or a list or grid view, use a toggle group.

**Do**

```tsx
<Tabs.Tab value='overview'>Overview</Tabs.Tab>
<Tabs.Tab value='settings'>Settings</Tabs.Tab>
```

Each tab opens its own panel with different content.

**Don’t**

```tsx
<Tabs.Tab value='7d'>7 days</Tabs.Tab>
<Tabs.Tab value='30d'>30 days</Tabs.Tab>
```

Tabs that only change the range of one chart. There are no separate panels, so use a [ToggleGroup](https://ticketsolutionsptyltd.github.io/roadie/components/toggle-group.md).

## Accessibility

- **Keyboard:** `Arrow` keys move focus between tabs (`ArrowLeft`/`ArrowRight` for horizontal, `ArrowUp`/`ArrowDown` for vertical). `Home` and `End` jump to the first and last tab. `Enter` and `Space` activate the focused tab.
- **Active-on-focus:** Pass `activateOnFocus` on `Tabs.List` to switch panels as focus moves, instead of waiting for `Enter`/`Space`.
- **ARIA:** Base UI handles `role='tablist'`, `role='tab'`, `role='tabpanel'`, `aria-controls`, `aria-selected`, and `aria-orientation` automatically.
- **Reduced motion:** The indicator's slide animation is disabled under `prefers-reduced-motion: reduce`. The indicator still updates position, just without the transition.

## API reference

### Tabs

```tsx
import { Tabs } from '@oztix/roadie-components/tabs'
```

- `className`: `string | ((state: TabsRootState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.
- `defaultValue`: `any`. Defaults to `0`. The default value. Use when the component is not controlled. When the value is `null`, no Tab will be active.
- `value`: `any`. The value of the currently active `Tab`. Use when the component is controlled. When the value is `null`, no Tab will be active.
- `onValueChange`: `((value: any, eventDetails: TabsRootChangeEventDetails) => void)`. Callback invoked when new value is being set. The event `reason` is `'none'` for user-initiated changes, such as a click or keyboard navigation; `'initial'` for the first automatic selection or fallback in uncontrolled roots when `defaultValue` is omitted or `undefined`, including when the implicit initial value is disabled or missing; `'disabled'` for automatic fallback when the selected tab becomes disabled in uncontrolled roots; or `'missing'` for automatic fallback when the selected tab is removed, or when an explicit `defaultValue` never matches a mounted tab in uncontrolled roots. For automatic changes, the selected value can be `null` when no enabled Tab is available as a fallback. Automatic changes cannot be canceled; calling `eventDetails.cancel()` for `'initial'`, `'disabled'`, or `'missing'` has no effect.
- `intent`: `"neutral" | "brand" | "brand-secondary" | "brand-blue" | "brand-orange" | "brand-purple" | "accent" | "danger" | "success" | "warning" | "info"`. Sets the intent cascade for descendant tabs.
- `emphasis`: `"strong" | "normal" | "subtle" | "subtler"`. Defaults to `'normal'`. Visual treatment for the list and indicator. See the emphasis ladder comment in `variants.ts` for the full description of each option.
- `size`: `"sm" | "md" | "lg"`. Defaults to `'md'`. Tab height and typography size.
- `direction`: `"horizontal" | "vertical"`. Defaults to `'horizontal'`. Layout flow direction. `vertical` stacks the tabs in a column. The active indicator follows the axis: in `subtler` emphasis the underline becomes a left-edge bar, while the pill emphases (`strong` / `normal` / `subtle`) keep their full-rect shape around the active tab.

### Tabs.Indicator

- `renderBeforeHydration`: `boolean`. Defaults to `true`. Whether to render itself before React hydrates. This minimizes the time that the indicator isn't visible after server-side rendering.
- `className`: `string | ((state: TabsIndicatorState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Tabs.List

- `activateOnFocus`: `boolean`. Defaults to `false`. Whether to automatically change the active tab on arrow key focus. Otherwise, tabs will be activated using <kbd>Enter</kbd> or <kbd>Space</kbd> key press.
- `loopFocus`: `boolean`. Defaults to `true`. Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys.
- `className`: `string | ((state: TabsListState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Tabs.Panel

- `value`: `any`. Required. The value of the TabPanel. It will be shown when the Tab with the corresponding value is active.
- `keepMounted`: `boolean`. Defaults to `false`. Whether to keep the HTML element in the DOM while the panel is hidden.
- `className`: `string | ((state: TabsPanelState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Tabs.Tab

- `value`: `any`. Required. The value of the Tab.
- `disabled`: `boolean`. Whether the Tab is disabled. If a first Tab on a `<Tabs.List>` is disabled, it won't initially be selected. Instead, the next enabled Tab will be selected. However, it does not work like this during server-side rendering, as it is not known during pre-rendering which Tabs are disabled. To work around it, ensure that `defaultValue` or `value` on `<Tabs.Root>` is set to an enabled Tab's value.
- `nativeButton`: `boolean`. Defaults to `true`. Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).
- `className`: `string | ((state: TabsTabState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.
- `href`: `string`. Render the tab as a routed anchor instead of a `<button>`. Useful for tabbed navigation where each tab maps to a route. Internal hrefs route through `RoadieLinkProvider`. Note: pressing Enter on a focused link-tab triggers native browser navigation immediately. For controlled `Tabs.value`, derive the value from the route (e.g. `usePathname()`) rather than from controlled local state — otherwise the active-tab indicator can flicker between selection and route change.
- `external`: `boolean`. Force external-link treatment when `href` is set.
- `target`: `string`. Override the auto `target='_blank'` default on external hrefs.

Docs updated 19 Oct 2026.
