Navigation
Tabs
Switch between panels of related content, with the full tab keyboard contract.
Examples
Line tabs
The default for page and panel-level navigation in dense UI: one row of triggers, a hairline underneath, and a single indicator that slides between them.
The panel swaps instantly. The indicator carries the state so the label hierarchy can stay quiet.
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="metrics" leadingIcon={<RiBarChartLine />}>
Metrics
</Tabs.Trigger>
<Tabs.Trigger value="data" leadingIcon={<RiTableLine />}>
Data
</Tabs.Trigger>
<Tabs.Trigger value="notes" disabled>
Notes
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">…</Tabs.Content>
</Tabs>Surface tabs
Inside a panel, where the strip is a view switcher rather than navigation: the indicator becomes a raised plate and the container carries the well.
The panel swaps instantly. The indicator carries the state so the label hierarchy can stay quiet.
<Tabs defaultValue="equity" variant="surface">
<Tabs.List>
<Tabs.Trigger value="equity" leadingIcon={<RiLineChartLine />}>
Equity
</Tabs.Trigger>
<Tabs.Trigger value="drawdown">Drawdown</Tabs.Trigger>
<Tabs.Trigger value="trades">Trades</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="equity">…</Tabs.Content>
</Tabs>Three sizes
`sm` lives in a card header, `md` is the default, and `lg` carries a page-level view switch. The height is the only thing that changes.
The panel swaps instantly. The indicator carries the state so the label hierarchy can stay quiet.
The panel swaps instantly. The indicator carries the state so the label hierarchy can stay quiet.
<Tabs defaultValue="s1" size="sm">…</Tabs>
<Tabs defaultValue="m1">…</Tabs>
<Tabs defaultValue="l1" size="lg">…</Tabs>A strip that fills its region
`fill` gives every trigger an equal share of the width, which is what a full-width card header or a two-option switch wants.
The panel swaps instantly. The indicator carries the state so the label hierarchy can stay quiet.
<Tabs defaultValue="chart">
<Tabs.List fill>
<Tabs.Trigger value="chart">Chart</Tabs.Trigger>
<Tabs.Trigger value="table">Table</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="chart">…</Tabs.Content>
</Tabs>Selection the caller owns
Pass `value` and `onValueChange` and the strip reports the switch — which is how a tab becomes part of the URL, of a saved view, or of a query.
The panel swaps instantly. The indicator carries the state so the label hierarchy can stay quiet.
Reported value
metricsconst [view, setView] = useState("metrics");
<Tabs value={view} onValueChange={setView}>
<Tabs.List>
<Tabs.Trigger value="metrics">Metrics</Tabs.Trigger>
<Tabs.Trigger value="trades">Trades</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="metrics">…</Tabs.Content>
<Tabs.Content value="trades">…</Tabs.Content>
</Tabs>In a chart panel header
The shape a product actually uses: a title, a badge and a small surface strip sharing one line, with the panel's own body underneath.
BTC/USD · 1d
+2.1%The panel swaps instantly. The indicator carries the state so the label hierarchy can stay quiet.
<Panel>
<Inline justify="between" className="w-full">
<Inline gap="sm">
<Text variant="label">BTC/USD · 1d</Text>
<Badge size="sm" tone="positive" dot>
+2.1%
</Badge>
</Inline>
<Tabs defaultValue="price" variant="surface" size="sm">
<Tabs.List>
<Tabs.Trigger value="price">Price</Tabs.Trigger>
<Tabs.Trigger value="volume">Volume</Tabs.Trigger>
<Tabs.Trigger value="volatility">Volatility</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="price">…</Tabs.Content>
</Tabs>
</Inline>
</Panel>Usage
import { Tabs } from "@luc/ui";<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="metrics">Metrics</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">…</Tabs.Content>
<Tabs.Content value="metrics">…</Tabs.Content>
</Tabs>`Tabs.List`, `Tabs.Trigger` and `Tabs.Content` are the same import as `Tabs`; a trigger and its panel are paired by `value`.
API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
`Tabs` is one symbol carrying its own parts: `.List` (adds `fill`), `.Trigger` (adds `leadingIcon`) and `.Content`. The table below is the root's props, which is where `variant`, `size` and the controlled pair live; the parts take the Radix props their names imply.
| Export | Kind | Signature |
|---|---|---|
Tabs | 9 documented props | (({ className, variant, size, value, defaultValue, onValueChange, children, ...props }: TabsProps) => import("@types/react").JSX.Element) & { List: ({ className, fill, children, ref, ...props }: Tabs… |
Tabs props
Type: TabsProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
activationMode?inherited | "manual" | "automatic" | undefined | — | Whether a tab is activated automatically or manually. |
asChild?inherited | boolean | undefined | — | |
defaultValue?inherited | string | undefined | — | The value of the tab to select by default, if uncontrolled |
dir?inherited | Direction | undefined | — | The direction of navigation between toolbar items. |
onValueChange?inherited | ((value: string) => void) | undefined | — | A function called when a new tab is selected |
orientation?inherited | "horizontal" | "vertical" | undefined | — | The orientation the tabs are layed out. Mainly so arrow navigation is done accordingly (left & right vs. up & down) |
size? | TabsSize | undefined | — | |
value?inherited | string | undefined | — | The value for the selected tab, if controlled |
variant? | TabsVariant | undefined | — |
Guidelines
Do
- Use `Tabs` for alternative views of one subject, where the label is the navigation and the reader can leave and come back.
- Give every enabled trigger a `Tabs.Content`, even an empty one, so the `aria-controls` relationship always resolves to a panel.
- Reach for `variant="surface"` when the strip sits inside a panel and `fill` when the triggers should divide the available width.
- Let the shared indicator carry the selection: it measures the two adjacent triggers and slides on the emphasized curve, which is why the labels can stay quiet.
Don't
- Do not use tabs for a value that is submitted with a form: that is a `SegmentedControl`, which is a toggle group and reports its value, while a tab reports which panel is showing.
- Do not grow the active label's weight — the row reflows and the shared indicator's own geometry moves with it.
- Do not put two tab strips in one region with different meanings; the reader has no way to tell which one the arrows belong to.
Accessibility
Radix owns the tab contract: one Tab stop for the strip, arrow keys move between triggers, Home and End jump to the ends, and each trigger carries `aria-selected` and `aria-controls`. The shared indicator is `aria-hidden` decoration, so the active state never depends on the animation — and under `prefers-reduced-motion` the indicator does not animate at all, it arrives.
Related