Tabs
Horizontal strip or vertical rail, in five variants, with counts, icons, descriptions, groups and arrow-key navigation.
Component behavior
Tabs provides navigation between related views.
orientation selects a horizontal strip or vertical rail. Both orientations support the same variant, size, tone, fill, align, and --m-tabs-* settings.
- Related views of one record, such as Overview, Logs, and Settings.
- Vertical settings navigation with
orientation="vertical"andvariant="panels". - Content panels that share one page route.
SegmentedControl: the control selects a value rather than a panel. SegmentedControl uses radiogroup semantics; Tabs uses tablist semantics.
Sidebar: destinations are separate application routes.
Accordion: several content sections can remain open simultaneously.
Stepper: the views are ordered and must be completed in sequence.
TabStrip: the interface represents browser tabs.
Props
TabItem.key supplies React identity, the onChange value, and the generated tab ID suffix. Keep keys stable. activeKey controls selection when defined; otherwise defaultActiveKey initializes internal state. onChange fires in either mode.
| Prop | Type | Default | Description |
|---|---|---|---|
items | TabItem[] | — | Tabs in display order. The first key is the uncontrolled default when defaultActiveKey is omitted. |
activeKey | string | — | Controlled selected key. A defined value enables controlled mode. Returning to undefined resumes internal state. |
defaultActiveKey | string | — | Initial uncontrolled key. Defaults to the first item key, or an empty string when items is empty. |
onChange | (key: string) => void | — | Called with the selected key after a click or keyboard selection. In controlled mode, update activeKey to apply the selection. |
orientation | "horizontal" | "vertical" | "horizontal" | Layout direction. Vertical tabs use Up and Down for navigation. |
variant | "line" | "pills" | "panels" | "segmented" | "readout" | "line" | Visual style: line, pills, panels, segmented, or readout. |
size | "sm" | "md" | "lg" | "md" | Sets padding, font size, spacing, and indicator thickness. |
tone | ToneProp | "primary" | Color for the indicator, active background, border, and icon. Labels retain neutral colors. |
fill | boolean | false | Distributes available width equally for horizontal tabs, or height equally for vertical tabs. |
align | "start" | "center" | "end" | "start" | Alignment along the tab list. Ignored when fill is true. |
showRail | boolean | — | Displays the border along the tab list. Defaults to true for line and false for other variants. |
showIndicator | boolean | — | Displays the active indicator. Defaults to true for line, readout, and vertical panels; false otherwise. |
wrap | boolean | false | Allows horizontal tabs to wrap onto multiple rows instead of scrolling. |
scrollFade | boolean | true | Shows fades at overflowing scroll edges. Ignored when tabs fit or wrap is enabled. |
scrollActiveIntoView | boolean | true | Scrolls the tab list to reveal the selected tab. Ancestor containers are not scrolled. |
iconPlacement | "leading" | "trailing" | "leading" | Which side of the label each item's icon sits on. |
countPlacement | "inline" | "edge" | — | Count position. Defaults to edge for vertical tabs and inline for horizontal tabs. Readout uses inline regardless of this value. |
showDescriptions | boolean | true | Displays item descriptions below labels. |
activation | "automatic" | "manual" | "automatic" | Automatic selects when focus moves with arrow keys. Manual requires Enter or Space to select. |
loop | boolean | true | Wraps keyboard navigation at the first and last enabled tab. |
idPrefix | string | — | Prefix for generated tab IDs. Defaults to a React useId value. |
as | ElementType | "div" | Root HTML element or component. |
renderTab | (item: TabItem, state: TabState) => ReactNode | — | Replaces tab content while retaining tab semantics, focus handling, and indicators. |
TabItem
| Prop | Type | Default | Description |
|---|---|---|---|
key | string | — | Identity of the tab: the React key, the value passed to onChange, and the tail of its generated id. |
label | ReactNode | — | The tab's name. Clips with an ellipsis rather than wrapping. |
count | ReactNode | — | Count content. Zero is displayed; omit the value to hide the count. |
icon | ReactNode | — | Icon content. The application supplies icon dimensions. |
description | ReactNode | — | Dim second line under the label. A vertical rail has the room for it; a horizontal strip usually does not. |
badge | ReactNode | — | Content next to the label. Remains beside the label when countPlacement is edge. |
group | string | — | Uppercase heading above this tab. Consecutive tabs repeating the same group sit under one heading. Vertical only. |
disabled | boolean | — | Prevents selection and skips the tab in keyboard navigation. Anchor tabs omit href while disabled. |
href | string | — | Renders an anchor instead of a button. Navigation is not intercepted. Arrow keys update tab selection without following the link. |
target | string | — | target for an href tab. "_blank" also gets rel="noopener noreferrer". Ignored without an href. |
panelId | string | — | id of the panel this tab controls, which becomes the tab's aria-controls. Pair it with aria-labelledby={`${idPrefix}-${key}`} on the panel. |
TabState
renderTab receives the following state for each tab.
| Prop | Type | Default | Description |
|---|---|---|---|
index | number | — | Position in items. |
active | boolean | — | This tab is the selected one. |
disabled | boolean | — | The item's disabled flag. |
select | () => void | — | Selects this tab. |
Variants
variant selects one of five visual styles. All styles share selection state, keyboard handling, and markup.
line is the default variant. It renders a border and an accent indicator for the active tab. Active labels use fg-strong; inactive labels use fg-dim.
pills uses tone-colored selection without a surrounding container. segmented uses a neutral container. panels renders tabs as tiles, matching Stepper.
The pills variant uses --m-tone-bg and --m-tone-border for the active surface. tone colors the fill, border, and icon. Labels use neutral foreground tokens.
Readout
variant="readout" displays counts prominently with small uppercase labels. The selected cell uses an elevated surface and tone-colored top edge.
Use readout tabs for category counts, such as Open, Acknowledged, and Resolved. fill distributes cells evenly; tone controls the active accent.
The readout variant arranges icon, label, badge, and count in a grid. Keyboard behavior and semantics are unchanged.
In vertical orientation, the readout label and count share a row, with counts aligned at the edge.
Motion
Fill, label, and icon changes use --m-transition-interaction, which defaults to 0s linear. Theme overrides can enable these transitions.
The active indicator expands along the tab axis. It is rendered within the active tab without measuring layout. Server and initial client markup match. prefers-reduced-motion disables this transition.
Vertical
orientation="vertical" uses a full-width rail with the indicator on the inline edge. Up/Down navigate vertical tabs; Left/Right navigate horizontal tabs.
Groups
group adds a heading above consecutive vertical tabs with the same group value. Groups apply only in vertical orientation.
Group wrappers are presentational. Each tab references its group heading through aria-describedby so assistive technology can announce the group.
Panels and the keyboard
Tabs renders navigation only. Each tab receives a stable ID from idPrefix or useId. TabItem.panelId supplies aria-controls. Set the corresponding panel’s aria-labelledby to the tab ID.
The tablist has one tab stop. Arrow keys move focus within the list; Home and End move to the ends. loop enables wrapping and disabled tabs are skipped. activation="automatic" selects on focus; activation="manual" selects with Enter or Space.
Item content
TabItem supports count, icon, badge, and description. Icons precede the label unless iconPlacement="trailing". Supply icon dimensions explicitly. description renders a secondary line; showDescriptions={false} hides descriptions.
Filling and alignment
Tabs use content width and start alignment by default. align centers or end-aligns them. fill distributes the available width or height evenly.
Vertical tabs align labels at the inline start and default countPlacement to edge. Filled horizontal tabs center content and keep counts inline. Set countPlacement="edge" for edge-aligned counts.
Overflowing tabs scroll by default. Set wrap to allow wrapping.
When the strip scrolls
A scrolling strip gets two behaviors by default.
Edge fades indicate additional scrollable content. The scrollbar is hidden. Set scrollFade={false} to disable fades and --m-tabs-fade to configure their length.
The selected tab scrolls into view within the tab container. Ancestor containers do not scroll. Set scrollActiveIntoView={false} to disable this behavior.
Scroll fades and active-tab scrolling run after layout measurement. Server rendering and the first client render show the tab list before these effects. See Server rendering.
Size and tone
size sets padding, typography, and indicator dimensions. tone sets the indicator, active surface, border, and icon colors. Labels retain neutral foreground colors.
Custom properties
Everything the component measures with is a custom property on the root, so density is tunable without restating a rule. Set them in style, or on any ancestor.
| Prop | Type | Default | Description |
|---|---|---|---|
--m-tabs-pad-x | length | — | Inline padding inside a tab. Set by size. |
--m-tabs-pad-y | length | — | Block padding inside a tab. Set by size. |
--m-tabs-gap | length | — | Between one tab and the next. |
--m-tabs-inner-gap | length | — | Between a tab's icon, label, badge and count. |
--m-tabs-font | length | — | Label size. The description is a step below it. |
--m-tabs-indicator | length | — | Thickness of the accent bar on the active tab. |
--m-tabs-rail | length | — | Thickness of the border along the strip. |
--m-tabs-radius | length | — | Tab corner radius. Defaults depend on variant. |
--m-tabs-indicator-rest | number | — | Initial indicator length as a fraction of the tab. Readout uses the full cell width. |
--m-tabs-fade | length | — | How far the contents fade out at an end a scrolling strip can still scroll towards. Longer than ScrollArea's 16px, a strip being a single row of type rather than a block of it. |
--m-tabs-cell | length | — | readout only: the minimum width of a cell, so a strip of them is not made ragged by its labels. Set by size. |
--m-tabs-bleed | length | — | Scroller padding for focus rings and indicator alignment. Zero can clip focus rings. |
Examples
Uncontrolled
Without activeKey, selection is internal and starts on the first item. Use controlled selection when other application state must read or change the active tab.
Controlled
Hoist the key when the URL, a query, or a sibling component needs it.
Disabled items
Disabled tabs remain visible, are removed from the tab order, and are skipped by arrow navigation.
Tabs that are also links
TabItem.href renders an anchor instead of a button. It retains tab roles, focus handling, the indicator, and onChange, and supports standard link operations such as opening a new tab.
Link clicks use normal navigation. Derive activeKey from the router when tabs represent routes. Arrow keys change selection without navigating. as="nav" sets the navigation landmark; set role="tablist" explicitly when tab semantics are also required.
import { Tabs } from "@solarius/onset";
import type { TabItem, TabState, TabsVariant } from "@solarius/onset";