Skip to content

Tabs

One set of panels, one of which is shown. Horizontal or vertical.

tsx
import { Tab, TabPanel, Tabs } from 'neba';

<Tabs defaultValue="overview">
  <Tab value="overview">Overview</Tab>
  <Tab value="usage">Usage</Tab>

  <TabPanel value="overview">Three deploys today, all green.</TabPanel>
  <TabPanel value="usage">1,284 build minutes used.</TabPanel>
</Tabs>;

There is no <TabList> wrapper. Everything written between the tags is either a tab or a panel, the two go in different boxes, and the component sorts them — rather than adding a wrapper you have to remember.

Props

Tabs

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the tab **bar**, not of the panels. solid is a segmented control, outline is a rule along the bar, text is the same without it
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The tabs' height and type scale, on Button's own ladder — so a tab bar keeps its baseline in a toolbar
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
valuestring | number | nullThe chosen tab, for a controlled set
defaultValuestring | number | nullWhich starts chosen
onValueChange(value: string | number | null) => voidCalled when the chosen tab changes
orientationshared'horizontal' | 'vertical''horizontal'Which way the bar runs. vertical stands the tabs down the side and moves the arrow keys onto the other axis
activateOnFocusbooleanfalseWhether the arrow keys also choose. Off by default: the moment one panel fetches, walking past four tabs fires four requests
loopFocusbooleantrueWhether the arrow keys wrap from the last tab back to the first
fullWidthbooleanfalseThe tabs share the full width of the bar, each taking an equal part
childrenReactNodeThe Tabs and the TabPanels. They are sorted into their two boxes for you, so there is no list wrapper to remember

Tab

PropTypeDefaultDescription
value * string | numberIdentifies the tab, and picks out the panel with the same value
startIconReactNodeContent before the label
endIconReactNodeContent after the label — a count, a Badge, a status dot
disabledbooleanfalseUnavailable, but still listed
childrenReactNodeThe tab's label

TabPanel

PropTypeDefaultDescription
value * string | numberWhich tab shows this panel
keepMountedbooleanfalseKeeps the panel in the DOM while hidden. For content that is expensive to build, or form state that should survive
childrenReactNodeThe content

Examples

Variants

variant here is the weight of the tab bar, not of the panels under it.

  • solid — a segmented control. The bar is a frosted trough and the indicator is a filled tile that slides between the tabs.
  • outline — the classic. A rule along the edge of the bar, with the indicator riding on it.
  • text — the same bar with the rule taken away. For tabs inside a Card that already has an edge of its own.

Orientation

A vertical bar is not a horizontal one turned on its side. Base UI moves the arrow keys onto the other axis with it, which is the part that makes a vertical tab bar reachable at all.

Icons and counts

The indicator moves its box

Base UI measures the chosen tab and writes the result onto --active-tab-left, --active-tab-width and their siblings. The indicator animates left/top and width/height from those.

That is a layout animation on an empty box, not a transform on a label — nothing with text in it moves, which is the line the no-transform rule actually draws.

It is also the one place in the library that reaches for a physical property (left) instead of a logical one, on purpose. --active-tab-left is a measurement: the distance in pixels from the list's left edge to the chosen tab's, and it stays a distance from the left under RTL. Pairing a physical measurement with a logical property is what would break the direction, not what would fix it. The edge the bar sits on is logical, because that one genuinely flips.

Why activateOnFocus is off by default

Automatic activation is only kind when every panel is already on the page. The moment one of them fetches, walking past four tabs with the arrow keys fires four requests.

Tabs are on the control ladder

A md tab and a md Button are the same 32px. That is what lets a tab bar sit in a toolbar next to a button without the row losing its baseline.

Accessibility

Base UI owns everything that makes a tab bar a tab bar rather than a row of buttons: roving focus so the whole bar is one tab stop, the arrow keys on whichever axis the bar runs, Home and End, the tab / tabpanel roles and the aria-controls wiring between them, and the measurement that puts the indicator under the chosen tab.

A panel with nothing focusable inside it takes focus itself, so the content is reachable by keyboard — and when it does, it gets the house focus ring rather than the browser's outline.

Released under the MIT License