# Tabs

> Switch between panels of related content in the same space.

URL: https://apogee.avaui.dev/docs/tabs
Last updated: 2026-10-16

Switch between panels of related content in the same space.

Last updated October 16, 2026

Uses JavaScript

Wires the WAI-ARIA tablist pattern: roving tabindex, arrow / Home / End keys, panel visibility (only the selected panel shows), edge fades when the list overflows, and scrolls the chosen tab into view.

**Without JS:** Every panel renders stacked and reachable — no content is hidden, so nothing is lost.

```astro
<Tabs tabs={['Account', 'Password', 'Team']}>
<div slot="panel-0">Manage your account details.</div>
<div slot="panel-1">Change your password.</div>
<div slot="panel-2">Invite and manage members.</div>
</Tabs>
```

## Add

```sh
npx @ava-ui/apogee add tabs
```

No Apogee dependencies. Ships one scoped JS island (progressive enhancement).

## Usage

```astro
---
import Tabs from '@/components/apogee/Tabs/Tabs.astro'
---
<Tabs tabs={['Account', 'Password']}>
  <div slot="panel-0">Account settings…</div>
  <div slot="panel-1">Password settings…</div>
</Tabs>
```

Labels come from the `tabs` array; each panel goes in a matching `panel-<i>` slot (0-indexed, same order as the labels).

## Anatomy

-   **Tablist** — the row of `role="tab"` buttons (the labels).
-   **Tabpanel** — one `role="tabpanel"` per tab; the first is shown, the rest are `hidden` until selected.

## API

`@composable` — exposed to Content Editors in AVA. Any other attribute passes through to the root `<div>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tabs` | `(string \| { label, value?, href?, disabled?, icon?, badge? })[]` | — | Tabs in order. A string is a label; an object adds a `value` (panel slot name and event value, defaults to the index), an `href` (renders a link, no panel), `disabled`, an `icon` (Lucide component) or a `badge` (count or short text, shown from `md`). |
| `value` | `string` | first enabled tab | Value of the tab shown first. |
| `variant` | `'bordered' \| 'borderless' \| 'pills' \| 'pills-raised' \| 'primary' \| 'feature'` | `'bordered'` | Underline with a list border, underline alone, chip, chip in a raised tray, a primary-tinted chip, or a feature tab (title and line over a thick bar). |
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | `vertical` stacks the list beside the panels; Up and Down move between tabs; underline variants draw on the start edge. |
| `fullWidth` | `boolean` | `false` | Tabs share the list width equally (horizontal only). |
| `activation` | `'automatic' \| 'manual'` | `'automatic'` | `manual`: arrows move focus, Enter or Space selects. Use it when a panel is expensive to show. |
| `label` | `string` | `'Sections'` | Name of the `<nav>` when every tab is a link. |
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Tab height and text size. |
| `class` | `string` | — | Extra classes on the root, merged last. |

Panels are slots named `panel-<value>`: `panel-0`, `panel-1`, … for string tabs, `panel-billing` for `{ value: 'billing' }`.

## Variants

Every `variant`:

```astro
<Tabs variant="bordered" tabs={['Account', 'Password', 'Team']}>…</Tabs>
<Tabs variant="borderless" tabs={['Account', 'Password', 'Team']}>…</Tabs>
<Tabs variant="pills" tabs={['Account', 'Password', 'Team']}>…</Tabs>
<Tabs variant="pills-raised" tabs={['Account', 'Password', 'Team']}>…</Tabs>
<Tabs variant="primary" tabs={['Account', 'Password', 'Team']}>…</Tabs>
```

## Feature tabs

`variant="feature"` makes each tab a title and a `description` over a thick bar, for feature sections; vertical feature tabs put the bar on the start edge. The title names the tab and the line describes it. Put the panels above the row with `flex flex-col-reverse` on the root.

```astro
<Tabs variant="feature" class="flex flex-col-reverse gap-8" tabs={[
{ label: 'Track what matters', description: 'Revenue, users and churn at a glance.' },
{ label: 'Know your customers', description: 'Every account, plan and seat in one table.' },
{ label: 'Set it up your way', description: 'Profile and preferences in one place.' },
]}>…</Tabs>
<Tabs variant="feature" orientation="vertical" tabs={[…]}>…</Tabs>
```

## Sizes

```astro
<Tabs size="small" tabs={['Overview', 'Billing']}>…</Tabs>
<Tabs size="medium" tabs={['Overview', 'Billing']}>…</Tabs>
<Tabs size="large" tabs={['Overview', 'Billing']}>…</Tabs>
```

## Items

Objects add a `value`, a link (`href`), `disabled` or a `description` (feature tabs); `value` on the root picks the tab shown first.

```astro
<Tabs
tabs={[{ label: 'Overview' }, { label: 'Billing', value: 'billing' }, { label: 'Archived', disabled: true }, { label: 'Help', href: '/docs/tabs#accessibility' }]}
value="billing"
>
<div slot="panel-0">Overview of the account.</div>
<div slot="panel-billing">Invoices and payment method.</div>
</Tabs>
```

Icons and badges:

```astro
<Tabs tabs={[{ label: 'Profile', icon: User }, { label: 'Billing', icon: CreditCard }, { label: 'Team', icon: Users, badge: 4 }, { label: 'Alerts', icon: Bell, badge: 12 }]}>…</Tabs>
```

## Orientation

`vertical` puts the list beside the panels, for settings pages:

```astro
<Tabs orientation="vertical" tabs={['General', 'Security', 'Notifications']}>…</Tabs>
```

## Full width

```astro
<Tabs fullWidth variant="pills-raised" tabs={['Monthly', 'Yearly']}>…</Tabs>
```

## Activation

`manual`: arrows move focus without switching panels; Enter or Space selects.

```astro
<Tabs activation="manual" tabs={['Summary', 'Report', 'Raw data']}>…</Tabs>
```

## Overflow

The list scrolls, the hidden edge fades, and the selected tab is scrolled into view.

```astro
<Tabs class="max-w-sm" tabs={['Overview', 'Analytics', 'Reports', 'Notifications', 'Integrations', 'Billing', 'Security', 'Members', 'Audit log', 'Exports']}>
<div slot="panel-0">…</div>
…
</Tabs>
```

The tab that starts active is not scrolled into view on load. Put it among the first visible tabs, or call `scrollIntoView` on it.

## States

-   **Selected** — the active tab has an accent underline and `aria-selected="true"`; its panel is visible.
-   **Hover / focus** — inactive tabs lighten on hover; keyboard focus shows a visible ring (`focus-visible:outline-focus`).
-   **Disabled** — dimmed, not clickable, skipped by the arrows.
-   **No JS** — every panel is shown (the `no-js:block` fallback).

## Examples

Every tab a link (category pages, a blog filter): the row becomes a `<nav>` named by `label`, and `value` marks the current page with `aria-current="page"`. Tab moves through the links; the arrow keys are left alone.

```astro
<Tabs label="Categories" value="design" tabs={[
{ label: 'View all', value: 'all', href: '/blog' },
{ label: 'Design', value: 'design', href: '/blog/design' },
{ label: 'Product', value: 'product', href: '/blog/product' },
]} />
```

More than a handful of tabs — they wrap; the pattern is unchanged:

```astro
<Tabs tabs={['Overview', 'Analytics', 'Reports', 'Notifications']}>
<div slot="panel-0">At-a-glance summary.</div>
<div slot="panel-1">Charts and trends.</div>
<div slot="panel-2">Exportable reports.</div>
<div slot="panel-3">Alert preferences.</div>
</Tabs>
```

## Events

The root dispatches `apogee:tabs-change` (`detail: { index, value }`, bubbling) whenever a tab is selected.

## Accessibility

| Key | Action |
| --- | --- |
| `Tab` | Moves focus into the tablist (one stop), then to the active panel. |
| `←` `→` | Move between tabs and select, wrapping at the ends; swapped under `dir="rtl"`. |
| `↑` `↓` | With `orientation="vertical"`, move between tabs instead of `←` `→`. |
| `Home` `End` | First / last tab. |
| `Enter` `Space` | With `activation="manual"`, select the focused tab (arrows then only move focus). |

-   Full **WAI-ARIA tabs pattern**: `role="tablist"` / `role="tab"` / `role="tabpanel"`, wired with `aria-selected`, `aria-controls`, and `aria-labelledby`.
-   A feature tab is named by its title (`aria-labelledby`) and described by its line (`aria-describedby`).
-   Each panel is focusable (`tabindex="0"`), so `Tab` from the active tab lands on its panel before moving on.
-   **Focus** — a visible `focus-visible` ring on each tab.
-   **No JS** — panels are never trapped: with scripts off every panel renders, so the content is fully reachable.
-   The panel enter animation respects `prefers-reduced-motion`.

## Tokens

| Token | Used for |
| --- | --- |
| `--border-neutral` | Tablist bottom rule + inactive tab border |
| `--border-primary` | Selected tab underline |
| `--foreground-neutral` / `--foreground-neutral-faded` | Active vs. inactive label colour |
| `--outline-color-focus` | Focus ring |
| `--duration-medium` / `--ease-standard` | Panel enter animation |

## Do / Don’t

-   **Do** keep labels to one or two words so the tablist stays scannable.
-   **Do** use tabs for parallel, peer-level content — not for a sequence (use `Steps`) or for progressive disclosure of one thing (use `Accordion`).
-   **Don’t** put a form that must submit as a whole across multiple tabs — a user can’t see all fields at once.
-   **Don’t** hide critical content behind a non-default tab if a no-JS user must act on it immediately (though it stays reachable, it’s below the fold).

## Content

-   Labels are nouns, sentence case, one or two words (“Account”, “Billing history”).
-   No ending punctuation. Don’t number them — the order is the sequence.

## Related

[

SegmentedControlA row of connected, mutually exclusive options backed by native radios — pick one from a small, always-visible set.

](https://apogee.avaui.dev/docs/segmented-control)[

ContentToggleShows one keyed child at a time and slides between them, for pagers and step content.

](https://apogee.avaui.dev/docs/content-toggle)[

ToggleButtonGroupConnects ToggleButtons into a segmented bar — single-select or multi-select.

](https://apogee.avaui.dev/docs/toggle-button-group)
