AntaTabs
Switch to dark theme
Search documentation
On this page

Tabs

A tablist for switching between views. The strip is data-driven: pass an options array (like RadioGroup), each entry a unique value plus a label (or children) and optional icon / iconTrailing. That alone is a selectable strip that emits the chosen value; add matching <TabPanel> children (see Panels) to show and hide the content too.

Each option can also set className, style, and data-* attributes on its individual <a-tab>.

Playground

Priority

OverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettings
<Tabs priority="primary" defaultValue="a"></Tabs> {/* default */}
<Tabs priority="secondary" defaultValue="a"></Tabs>
<Tabs priority="tertiary" defaultValue="a"></Tabs>

priority sets the selected-tab indicator:

  • primary (default): a pill on a track.
  • secondary: a filled tab, no track.
  • tertiary: an underline.

Tone

Six named tones: neutral (default), brand, info, success, warning, critical. Any CSS color works for a one-off custom tone. The tone runs through the strip’s labels, indicator, and track at every priority. Rows are tones (the last is a custom color); columns are priorities.

Primary

OverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettings

Secondary

OverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettings

Tertiary

OverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettingsOverviewActivitySettings
<Tabs tone="brand" priority="secondary" defaultValue="a"></Tabs>
<Tabs tone="#e0457b" priority="tertiary" defaultValue="a"></Tabs> {/* any CSS color */}

Per-tab tone. A tone on an individual option overrides the strip’s tone for that one tab: it colors the label and icons in every mode, and the indicator when it’s the active tab. A named tone colors the sliding indicator too; a custom literal color tones the label everywhere but the indicator only under noslide (the single shared sliding element can’t read a descendant’s color).

OverviewApprovedFlaggedRejected
<Tabs
defaultValue="overview"
label="Review queue"
options={[
{ value: 'overview', label: 'Overview' },
{ value: 'approved', label: 'Approved', icon: 'circle-check', tone: 'success' },
{ value: 'flagged', label: 'Flagged', tone: 'warning' },
{ value: 'rejected', label: 'Rejected', tone: 'critical' },
]}
/>

Size

OneTwoThreeOneTwoThreeOneTwoThree
<Tabs size="small" defaultValue="a"></Tabs>
Three sizes reuse Button’s type scale, so a strip lines up with same-size buttons and inputs: small, medium (default), large. Icons scale with the tab.

Orientation

horizontal (default) lays the tabs in a row; vertical stacks them into a column. The arrow keys follow the axis: / when horizontal, / when vertical. To put panels beside a vertical strip, wrap <Tabs> in your own flex row (the strip and panels are siblings; see Panels). Vertical works at every priority:

Primary

GeneralMembersIntegrations

Secondary

GeneralMembersIntegrations

Tertiary

GeneralMembersIntegrations
<Tabs
orientation="vertical"
priority="tertiary"
defaultValue="general"
options={[
{ value: 'general', label: 'General' },
{ value: 'members', label: 'Members' },
{ value: 'integrations', label: 'Integrations' },
]}
/>

Icons and content

A tab option takes a leading icon and a trailing iconTrailing; here the trailing dot flags an unsaved file. For more than text, give the option children instead of label, like a Tag counter.

app.tsx 2README.mdstyles.css
<Tabs
defaultValue="app"
label="Open files"
options={[
{ value: 'app', icon: 'braces', children: <>app.tsx <Tag size="small" value="2" /></> },
{ value: 'readme', label: 'README.md', icon: 'file' },
{ value: 'styles', icon: 'file', iconTrailing: 'circle-small-solid', children: 'styles.css' },
]}
/>

Overflow

OverviewOverviewActivityActivityRepositoriesRepositoriesPull requestsPull requestsDiscussionsDiscussionsMembersMembersIntegrationsIntegrations

When the tabs don’t fit, their labels truncate with an ellipsis; the strip never widens the page. To scroll or wrap labels instead, see Styling.

Set an option’s tooltip and a clipped tab reveals its full label on hover; tabs that fit show nothing. It renders as a truncatedOnly Tooltip anchored to the tab, so it shows only when the label ellipsizes. Hover the truncated tabs above to see it.

<Tabs
defaultValue="overview"
label="Sections"
options={sections.map(({ value, label }) => ({
value,
label,
tooltip: label,
}))}
/>

Panels

AccountSecurityBilling

Profile, email, and password.

Two-factor auth and active sessions.

Plan, invoices, and payment method.

{/* The strip and panels are flat siblings — you arrange them. A flex column gaps
them; the strip centers itself (a <Tabs> `style` lands on <a-tabs>). */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Tabs
defaultValue="account"
label="Settings"
style={{ alignSelf: 'center' }}
options={[
{ value: 'account', label: 'Account', icon: 'home' },
{ value: 'security', label: 'Security' },
{ value: 'billing', label: 'Billing' },
]}
>
<TabPanel value="account"><AccountForm /></TabPanel>
<TabPanel value="security"><SecurityForm /></TabPanel>
<TabPanel value="billing"><BillingForm /></TabPanel>
</Tabs>
</div>

Add a <TabPanel value="…"> child for each option; each panel shows itself when its value is active. The panel names its tab as its accessible label (aria-labelledby, off-DOM). Panel content is arbitrary React, so it renders normally.

Panels are optional. Without panels, Tabs renders the strip. Listen to onStateChange / onValueChange and render the content yourself.

JSX vs DOM

The strip renders from the options array; each <TabPanel> renders a self-managing <a-tabpanel>. Tabs never reads or toggles the panels: there’s no child introspection, so it renders the same in static SSR and works with any renderer. There is no wrapper element: the JSX becomes the strip followed by the panels as flat siblings, and className / id / style / ...rest land on the strip. Arranging them is up to you (see below):

<a-tabs role="tablist">
<a-tab role="tab" value="account" tabindex="0"><a-tab-label>Account</a-tab-label></a-tab>
</a-tabs>
<a-tabpanel role="tabpanel" value="account"></a-tabpanel> <!-- active: shown -->
<a-tabpanel role="tabpanel" value="security"></a-tabpanel> <!-- inactive: display:none -->

A horizontal strip stacks above its panels in normal flow with no extra markup; for a vertical strip beside its panels, wrap <Tabs> in your own flex row. The panels find the strip as a sibling under the same parent (a-tabpanel:scope > a-tabs), so keep them together; to split them into separate layout regions, drive a controlled value and render the panels yourself.

Each <a-tabpanel> sits beside <a-tabs> (its sibling, never inside it, so a panel never lands in the primary track). It reads the active value from <a-tabs> and shows/hides itself via its own off-DOM :state(active); nothing writes hidden / inert on it. The panel↔tab link is off-DOM too (internals.ariaLabelledByElements), so no id wiring is needed. No web component writes the light DOM, only your JSX does, which keeps a worker-thread reactive engine in sync.

Hiding an inactive panel

A <TabPanel> stays mounted while inactive (its DOM, form values, and scroll survive tab switches); hideMode picks how it hides:

  • display (default): display:none; leaves the tab order + a11y tree.
  • visibility: visibility:hidden, keeping its layout box (to avoid reflow or to measure a hidden panel).
<TabPanel value="b" hideMode="visibility"><Chart /></TabPanel>

To not render an inactive panel at all (unmount it to reset its state or defer an expensive subtree, or mount it lazily on first open), drive selection with a controlled value and render the panels yourself. Tabs passes panels straight through, so you decide what’s mounted:

const [tab, setTab] = useState('a')
// Unmount inactive — only the open panel exists:
<Tabs value={tab} onStateChange={(_e, { next }) => next && setTab(next)} options={}>
{tab === 'a' && <TabPanel value="a"><Expensive /></TabPanel>}
{tab === 'b' && <TabPanel value="b"><Form /></TabPanel>}
</Tabs>
// Lazy — mount on first open, keep after (track a "seen" set in state):
{seen.has('b') && <TabPanel value="b"><Heavy /></TabPanel>}

The same controlled pattern places the strip and panels in different layout regions: render <Tabs options={…} value onStateChange> in one place and your content wherever you like, switching on the value yourself.

Controlled vs. uncontrolled

The active tab is identified by one option’s value string. Tabs marks the matching tab selected and shows the corresponding <TabPanel value="…">, when one exists. Choose either uncontrolled or controlled state:

Uncontrolled. Pass defaultValue (an option’s value). Tabs then updates the active tab after interaction:

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

Controlled. Pass value (an option’s value) and onStateChange. onStateChange fires before the change applies. Set value to the new tab to accept it, or leave it unchanged to reject it.

const [tab, setTab] = useState('overview')
<Tabs
value={tab}
onStateChange={(e, { next }) => setTab(next)}
options={[
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
]}
/>

Keyboard & accessibility

The selected tab is in the page’s tab order. Once focus enters the strip, the arrow keys move between enabled tabs (wrapping at the ends), Home / End jump to the first / last, and Space / Enter activate the focused tab; arrow / Home / End navigation activates as it moves (selection follows focus). Disabled tabs are skipped and dropped from the tab order.

Pass label for the tablist’s accessible name. Each <TabPanel> names its tab as its accessible label (aria-labelledby), set off-DOM so nothing writes the panel’s attributes. Static panel content remains readable without making the panel a tab stop. Set tabIndex={0} when you want a text-only panel itself to receive focus; when the panel starts with a focusable control, Tab reaches that control directly.

Routing

Tabs reports the picked value and leaves navigation to the app, so a strip can switch routes as readily as panels. Treat each value as a path: control value from the current location and navigate in onStateChange (the pick fires on click; onValueChange waits for value to change, which for a route happens only after you’ve already navigated). Skip the <TabPanel>s, since the router renders the view.

<Tabs
value={pathname}
onStateChange={(_e, { next }) => next && navigation.navigate(next)}
options={[
{ value: '/settings/profile', label: 'Profile' },
{ value: '/settings/billing', label: 'Billing' },
]}
/>

Swap navigation.navigate for whatever the app uses (router.push, Astro’s navigate, location.assign). Controlling value from the route keeps the right tab active through back/forward and deep links. To guard a switch (unsaved edits), veto it in onStateChange with event.preventDefault().

Events

Three callbacks, in firing order:

CallbackWhenCancelablePayload
onStateChange(e, { next, prev })before a pick appliesnext / prev value
onChange(e)after selection appliesnative change event
onValueChange(e, { value })after selection applies{ value }

Use onStateChange when you control value: it fires on the user’s pick before anything applies, so it’s your chance to set the new value. Applying it (or the element self-applying, uncontrolled) then fires onChange / onValueChange, so those suit an uncontrolled strip reacting to its own pick. onFocus / onBlur report focus entering and leaving the strip (wired to focusin / focusout, since focus lands on a tab).

// Uncontrolled: the strip owns selection, so onValueChange fires on each pick.
<Tabs defaultValue="a" onValueChange={(_e, { value }) => route(value)}></Tabs>

Tabs props

Prop Type Default Description
children? ReactNode Optional <TabPanel value="…"> panels, one per tab value. Each is a self-managing <a-tabpanel> that shows itself when its value is the active tab. Omit them to use Tabs as a bare selectable strip. To place panels in a different layout region, or to unmount an inactive panel, drive selection with a controlled value and render the content yourself (see the docs).
options? TabOption[] The tabs, as a data array (the strip's single source). Each entry is a TabOption (value, label or children, icon, iconTrailing, tone, disabled, round, className, style). className and style land on that option's individual <a-tab>, not on the strip.
value? string Controlled active value — the tab value to mark selected (and, when a <TabPanel value="…"> shares it, the panel to reveal). When set, you own selection: the strip renders exactly what this says, and a user pick only requests a change via onStateChange — apply it by updating this prop. Leave undefined (and use defaultValue) for uncontrolled.
defaultValue? string Initial active value for the uncontrolled case. After first render Tabs owns selection itself.
onStateChange? (event, detail) => void Fired whenever the active tab changes — event-first. detail is { next, prev } (values; null = none). Cancelable: event.preventDefault() vetoes it (uncontrolled), or in controlled mode answer by updating value.
onChange? (event) => void Fired after the active tab changes — a native change event.
onValueChange? (event, attrs) => void Like onChange, but with a { value } snapshot as the 2nd argument.
onFocus? (event) => void Focus entered the strip (any tab) — wired to focusin (focus lands on a tab, not the tablist).
onBlur? (event) => void Focus left the strip entirely — wired to focusout.
label? string Accessible name for the tablist (aria-label).
priority? primarysecondarytertiary 'primary' Visual priority. primary is the raised pill on a recessed track (the segmented-control look); secondary keeps that sizing but drops the track, marking the selected tab with a subtle active background fill; tertiary is a bottom-underline indicator under the selected tab (no track, no rest line). tone colors secondary + tertiary; primary stays neutral.
tone? neutralbrandcriticalinfosuccesswarningstring 'neutral' Tone applied to the selected indicator/label, or any literal CSS color for a one-off custom tone (derived in oklch). Named tones track light/dark.
size? smallmediumlarge 'medium' Size — small 24px · medium 28px · large 32px tall, matching Button's scale (the tab's label leading runs a touch tighter, offset by 1px more block padding per side).
orientation? horizontalvertical 'horizontal' Layout + arrow-key axis. Horizontal ellipsizes labels when tabs overflow (scroll is opt-in via CSS); vertical stacks them.
fill? boolean false Makes horizontal tabs share the available inline space equally.
noslide? boolean Disable the sliding indicator. By default the selected-tab indicator animates between tabs (a single rectangle, via CSS anchor positioning); noslide paints it per tab so it snaps with no movement. (Browsers without anchor positioning get that per-tab paint automatically — noslide is the explicit opt-out.)
round? booleannumberstring Fully-round the tabs and the sliding indicator (and the primary track well). Applies strip-wide; a single tab's round rounds just that tab. A number (px) or CSS length string sets a custom radius on the top-level track well only — the tab pills + indicator stay fully round.
disabled? boolean Disable the whole strip.
Inherited props (className, id, slot, style, tabIndex, title)
Prop Type Default Description
className? string CSS class on the component's root element (merged with the component's own classes). Use it directly for layout and positioning — grid/flex placement, margins, alignment — rather than wrapping the component in a <div>/<span>.
id? string HTML id attribute.
slot? string Assigns the element to a named <slot> of a parent web component (e.g. slot="header" inside a <Card>, slot="footer" inside a <Dialog>).
style? CSSProperties Inline styles on the component's root element. Set layout/positioning here (or via className) directly on the component instead of adding a wrapper.
tabIndex? number Tab order. Set to -1 to skip the element when tabbing.
title? string HTML title attribute — native browser tooltip on hover.

Plus every standard DOM event handler (onClick, onFocus, onKeyDown, …) and any data-* or aria-* attribute — forwarded as-is to the underlying <a-*> element.

Tab option props

Prop Type Default Description
value string This tab's identity — pairs it with the <TabPanel value="…"> of the same value, and the value reported by onStateChange / onChange. Unique per strip.
label? ReactNode Visible label. The string shorthand for the tab's content; for richer content pass children instead (label wins when both are set).
children? ReactNode Tab content when you need more than a string — used if label is omitted.
icon? IconShape Leading icon shape, rendered before the label.
iconTrailing? IconShape Trailing icon shape, rendered after the label.
round? boolean Fully-round just this tab's box. <Tabs round> rounds the whole strip (tabs + sliding indicator) instead.
tone? neutralbrandcriticalinfosuccesswarningstring inherits the strip's `tone Per-tab tone override, same vocabulary as <Tabs tone> — colors this one tab's label + icons (all priorities/modes, named or custom color) and, when it's the active tab, its indicator. For a custom literal color the sliding indicator can't adopt it (the shared moving element can't read a descendant's color), so a custom tone colors the label everywhere and the indicator only in noslide; the six named tones color both in every mode. Overrides the strip's tone for this tab.
disabled? boolean Prevent user activation of this tab. A disabled tab can still indicate the current selection.
tooltip? ReactNode Tooltip for this tab — a string or any node — shown only when one of the tab's ellipsizing label parts is truncated (tabs ellipsize when the strip overflows), so clipped content reveals its full text on hover while a tab that fits shows nothing. Rendered as a truncatedOnly <Tooltip> anchored to the tab. For an always-visible tooltip or other custom trigger content, use children with your own <Tooltip> instead.
className? string CSS class on the option's rendered row.
style? CSSProperties Inline styles on the option's rendered row.

TabPanel props

Prop Type Default Description
value string Pairs this panel with the tab (options entry) of the same value.
hideMode? displayvisibility display How this panel hides while inactive: display (default — removed from layout and the a11y tree) or visibility (keeps its layout box, to measure it or avoid reflow). Both stay mounted; to not render an inactive panel, render it conditionally off a controlled value (see the Tabs docs).
Inherited props (children, className, id, slot, style, tabIndex, title)
Prop Type Default Description
children? ReactNode Child elements. When provided, replaces the component's default label/content.
className? string CSS class on the component's root element (merged with the component's own classes). Use it directly for layout and positioning — grid/flex placement, margins, alignment — rather than wrapping the component in a <div>/<span>.
id? string HTML id attribute.
slot? string Assigns the element to a named <slot> of a parent web component (e.g. slot="header" inside a <Card>, slot="footer" inside a <Dialog>).
style? CSSProperties Inline styles on the component's root element. Set layout/positioning here (or via className) directly on the component instead of adding a wrapper.
tabIndex? number Tab order. Set to -1 to skip the element when tabbing.
title? string HTML title attribute — native browser tooltip on hover.

Plus every standard DOM event handler (onClick, onFocus, onKeyDown, …) and any data-* or aria-* attribute — forwarded as-is to the underlying <a-*> element.

Web Component

Use the web component directly when you are not using React or Preact and a native control does not fit.

Keep the tab strip and panels as siblings under one parent. Panels read the active value from <a-tabs>. Give the selected tab tabindex="0" and the others tabindex="-1"; <a-tabs> moves focus and selection with the arrow keys, while your change handler updates those attributes for the next visit to the strip. Add tabindex="0" to a text-only panel only when you want the panel itself in the page Tab sequence.

Add fill to make horizontal tabs share the available width equally.

AccountSecurityAccount settingsSecurity settings
<div>
<a-tabs role="tablist" default-state="account" fill>
<a-tab role="tab" value="account" tabindex="0"><a-tab-label>Account</a-tab-label></a-tab>
<a-tab role="tab" value="security" tabindex="-1"><a-tab-label>Security</a-tab-label></a-tab>
</a-tabs>
<a-tabpanel role="tabpanel" value="account">Account settings</a-tabpanel>
<a-tabpanel role="tabpanel" value="security">Security settings</a-tabpanel>
</div>
const tabs = document.querySelector('a-tabs')
tabs.addEventListener('change', () => {
for (const tab of tabs.querySelectorAll('a-tab')) {
tab.tabIndex = tab.getAttribute('value') === tabs.value ? 0 : -1
}
})

Styling

Reach for the props first: tone, size, priority. The focus ring is the global --focus-ring (see Colors).

For anything else the strip is light-DOM: <a-tabs> the tablist, each <a-tab> a tab (its text wrapped in <a-tab-label>), <a-tabpanel> a panel, and the moving indicator is <a-tabs>’s ::before. Target them with ordinary CSS (an un-layered rule beats @layer anta without !important) and reach for real properties (border-radius, padding, background, …), not the internal --* output tokens. The demo classes (.square-tabs, .glow-tabs, …) are hooks. Replace them with selectors you own.

Roomier track. The strip hugs its tabs by default (0 padding), so it’s exactly a same-size button tall. For a gap around the selected pill (the classic “well” look), add padding and bump border-radius by the same amount, so the outer corner stays concentric with the tabs’ 4px. The strip grows 2×the padding taller.

OverviewActivitySettingsOverviewActivitySettings
a-tabs.roomy-1 { padding: 1px; border-radius: 5px; } /* 4 + 1 */
a-tabs.roomy-3 { padding: 3px; border-radius: 7px; } /* 4 + 3 */

Squarer primary. Square corners, heavier labels, and a roomier 4px track with no border or ring on the strip (box-shadow: none drops the default track ring) — just a 1px square ring on the raised pill. The track is <a-tabs>, the sliding pill its ::before:

OverviewActivitySettings
a-tabs.square-tabs { border-radius: 0; padding: 4px; box-shadow: none; } /* no track border / ring */
.square-tabs a-tab { border-radius: 0; font-weight: 600; }
a-tabs.square-tabs::before { border-radius: 0; box-shadow: 0 0 0 1px light-dark(#cfcfd6, #46464e); } /* 1px square pill ring */

Tertiary glowing underline. Recolor the sliding underline and add a radial highlight rising from it. The slider is a full-cover box whose border-bottom is the line, so a background gradient on it fills the tab behind the label. Because it’s one element, the glow slides with the line between tabs, with no noslide and no separate per-tab background.

OverviewActivitySettings
/* The class is on the <a-tabs> element itself, so target a-tabs.glow-tabs (not a descendant).
The tertiary slider is a full-cover box with a border-bottom line — recolor the border and
fill it with a gradient; both slide together. Where CSS anchor positioning isn't supported
there's no slider — tertiary falls back to the per-tab ::after underline (a plain line). */
a-tabs.glow-tabs::before {
border-bottom-color: light-dark(#8067ff, #907aff); /* recolor the sliding line */
background: /* highlight rising from the line, slides with it */
radial-gradient(80% 100% at 50% 100%,
color-mix(in oklch, light-dark(#8067ff, #907aff) 30%, transparent), transparent 70%);
}

Scroll instead of ellipsizing. Keep full labels and let the strip scroll horizontally: pin each tab to its content width (flex: none) and make the strip scrollable.

OverviewActivitySettingsMembersBillingIntegrationsNotificationsPermissionsAudit log
.scroll-tabs a-tabs { overflow-x: auto; scrollbar-width: thin; }
.scroll-tabs a-tab { flex: none; }

Equal-width tabs. By default tabs are sized to their labels (and compress only when the strip overflows). Pass fill for a segmented-control look where every tab shares the strip width:

AllAssigned to meRecentArchived
<Tabs
fill
defaultValue="all"
label="Filter"
options={[
{ value: 'all', label: 'All' },
{ value: 'assigned', label: 'Assigned to me' },
{ value: 'recent', label: 'Recent' },
{ value: 'archived', label: 'Archived' },
]}
/>

Wrap labels. Let long labels wrap so the tabs grow taller instead of truncating:

Quarterly revenueCustomer retentionProduct analytics
.wrap-tabs a-tab,
.wrap-tabs a-tab-label { white-space: normal; text-overflow: clip; overflow: visible; text-align: center; }

Disable the slide. By default the selected-tab indicator slides between tabs (a single rectangle, via CSS anchor positioning). Pass noslide to paint it on each tab instead, so the highlight snaps with no movement. It’s also the automatic fallback where anchor positioning isn’t supported.

OverviewActivitySettings
<Tabs noslide defaultValue="a"></Tabs>

Fully-rounded pill. The built-in round prop rounds the track, every tab, and the sliding pill to 999px in one flag, no CSS needed (it drives --tab-radius, which feeds the tabs, the moving indicator, and the primary track well):

OverviewActivitySettings
<Tabs round defaultValue="a" label="Sections"></Tabs> {/* pill track + tabs + indicator */}