Tabs
Switch between panels of related content in the same space.
Last updated
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.
Show codeHide code
<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
npx @ava-ui/apogee add tabs
No Apogee dependencies. Ships one scoped JS island (progressive enhancement).
Usage
---
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 arehiddenuntil 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:
Show codeHide code
<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.
Show codeHide code
<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
Show codeHide code
<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.
Show codeHide code
<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:
Show codeHide code
<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:
Show codeHide code
<Tabs orientation="vertical" tabs={['General', 'Security', 'Notifications']}>…</Tabs>Full width
Show codeHide code
<Tabs fullWidth variant="pills-raised" tabs={['Monthly', 'Yearly']}>…</Tabs>Activation
manual: arrows move focus without switching panels; Enter or Space selects.
Show codeHide code
<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.
Show codeHide code
<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:blockfallback).
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.
Show codeHide code
<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:
Show codeHide code
<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 witharia-selected,aria-controls, andaria-labelledby. - A feature tab is named by its title (
aria-labelledby) and described by its line (aria-describedby). - Each panel is focusable (
tabindex="0"), soTabfrom the active tab lands on its panel before moving on. - Focus — a visible
focus-visiblering 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 (useAccordion). - 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.