Components/Navigation
Tabs
Tabs are a row of labels that switch a surface between alternative views of itself, one at a time, without leaving the page. The row doubles as the map: it says what else is there before the reader commits to looking. A label carries all of that, so it has to say what its section holds.
import { Tabs } from '@growth-protocol-ai/alpha';When to use
Reach for tabs when one surface holds several alternative views of the same subject and a reader wants one of them at a time — a report’s sections, a record’s detail beside its history. They come into their own once the set is long enough that it will not always fit: the row measures itself and keeps the selection on screen at any width, which is the thing no other switcher in this system does.
When not to use
A row of Buttons is this system’s default sub-navigation, and a short, fixed set of views should stay one — reach for tabs when that row reads too heavy beside the content it sits over, or holds more options than fit it cleanly. Tabs are also not navigation: they change what a surface shows and never the address, so anything that moves the reader to another page belongs in the nav, and a set they can hold several of at once is not a tab row at all.
overflow2 values
What the row does when its container is narrower than its tabs. Collapsing is most of the reason to reach for tabs over a row of buttons once there are more than a handful of options, so it is the default; the alternative is for a short, fixed set, where measuring would be watching something that cannot change.
| overflow | When to use |
|---|---|
collapse | Tabs that no longer fit leave the row and collect behind a trigger in the last slot. Tabs that no longer fit leave the row and collect behind the trigger in declaration order, and the row keeps itself full — so a wide tab can drop out while a narrower one after it stays, and the rest shift into the space it left. Widen the container and they return in order. Widen it again and they come back in order. |
none | Nothing is measured and there is no trigger. A row too narrow for its tabs scrolls sideways instead, which is only worth having when you already know the set is short. |
| State | When to use |
|---|---|
The selection | A single baseline runs under every cell, and the selected one thickens its share of it in the primary colour. Nothing fills, nothing shifts and no label changes weight, which is what keeps a row of these quieter than a row of buttons. Hovering another tab paints exactly the same underline, because hover here is a preview of the click. |
A tab that will not take | A disabled item dims its label and ignores activation while staying in the row and staying reachable: it carries aria-disabled rather than the disabled attribute, so the arrow keys still land on it and a reader is told why it is there rather than left to notice it is missing. |
The selection behind the trigger | Once the selected tab is one of the ones that no longer fit, the trigger takes its label and its underline, and its accessible name becomes that label followed by the overflow label. The selection is therefore on screen at every width, in its own cell or surfaced on the trigger. The selection is always on screen, so picking a hidden section brings it into the row. The row re-measures after the pick, and a longer borrowed label widens the trigger — which can cost the row one more tab. |
Pass an aria-label. The row announces itself as a tablist wherever it lands, and an unnamed one is read as an anonymous group of tabs — the label is what says which group.
The row is one tab stop and selection is manual. Tab moves into it once, onto whichever tab is selected; the arrow keys move between tabs without selecting anything, Home and End jump to the ends, and Enter or Space commits. Arrowing past a view the reader does not want therefore costs them nothing, which is the whole reason selection is not automatic.
Start the row with a selection. One rendered with neither value nor defaultValue draws an unbroken baseline and marks nothing, which reads as a surface that has not loaded rather than one waiting to be asked.
Give every item a stable value. One derived from an array index moves the selection when the list reorders, and two items sharing one select together.
One in-content switcher per surface. Tabs beside a row of buttons doing the same job is two patterns competing to say the same thing, and the reader has to work out which of them moves the page.
Tabs are single-select and they switch views, not destinations. A yes or no is a Toggle, a set the reader holds several of at once is not this component, and moving between pages is navigation.
Keep labels short. A long one does not break the row — it is measured — but it collapses the tabs after it sooner, and the reader loses options to a word you could have cut.
Do not fight the collapse by sizing the container to the tabs. The trigger exists so the row survives a sidebar opening or a panel being dragged narrower; a row that only works at one width did not need tabs.
Wiring the row to its panels
The component renders the row and nothing under it — what a tab shows is a panel of yours, swapped on the selection you hold. Point each item’s controls at that panel’s id and give the panel role="tabpanel", so the two halves are one widget to a screen reader rather than a strip of buttons above some text.
Line items, tax and the total, as the invoice was issued.
Every send, view and payment attempt, most recent first.
What your team has said about this invoice, and to whom.
| Owner | Prop | Type | Default | Notes |
|---|---|---|---|---|
Tabs | items | readonly TabItem[] | [] | The tabs, in the order they are drawn. The row is driven by this array rather than by children, so there is nothing to compose inside it and every tab is described the same way. |
Tabs | value | string | — | Controlled selection, by item value. Pass it with onValueChange when the selection lives in your state — which it must, if anything else on the surface reads it. |
Tabs | defaultValue | string | — | The selection to start on when the row keeps its own. Given neither this nor value, the row renders with nothing selected. |
Tabs | onValueChange | (value: string) => void | — | Fires for a click, for Enter or Space on a focused tab, and for a pick out of the overflow menu. Nothing distinguishes the three — a selection is a selection. |
Tabs | overflow | "collapse" | "none" | "collapse" | Whether a row too narrow for its tabs collapses its tail behind a trigger or scrolls sideways instead. |
Tabs | overflowLabel | string | "More tabs" | The accessible name of the overflow trigger. Once the trigger has taken a collapsed selection, that tab’s label is read first and this follows it. |
Tabs | tabs-overflow | [data-slot] | — | The overflow menu’s panel, portalled out of the row. It is the dropdown’s panel down to the values, because it is the same thing: a list of choices. |
Tabs | tabs-overflow-item | [data-slot] | — | One row in that menu. None of them is ticked — the trigger above is already saying which section is selected, and marking it twice would make the panel about the selection rather than about what else there is. |
TabItem | value | string | — | Required: it is the identity onValueChange reports and the name value and defaultValue refer to. |
TabItem | label | string | — | What the cell shows, and what the trigger borrows when this tab is the collapsed selection. Text rather than a node, because the trigger has to be able to truncate it. |
TabItem | disabled | boolean | false | Refuses activation without leaving the row, and stays focusable and reached by the arrow keys. |
TabItem | controls | string | — | The id of the panel this tab shows, written onto the tab as aria-controls. Set it whenever you render panels; the row renders none of its own. |