TabBar
The persistent bar of top-level destinations at the foot of a phone screen, marking one as current with a heavier label and a top indicator rather than colour.
Preview
tab-bar · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/tab-bar
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { TabBar } from "@/components/ui/tab-bar"<TabBar
label="Main sections"
value={section}
onValueChange={setSection}
items={[
{ key: "home", label: "Home", icon: <House /> },
{ key: "trends", label: "Trends", icon: <Activity /> },
{ key: "learn", label: "Learn", icon: <BookOpen /> },
{ key: "settings", label: "Settings", icon: <Settings /> },
]}
/>When to use it
Use it when
- Moving between two to five top-level sections of a phone app, where the destinations are peers.
- Keeping the whole set of destinations in view, so any section is one tap away.
Do not use it when
- Switching between views that live inside one screen and share its space.Use
tabsinstead. - Setting one parameter of a single view, such as a chart's day or week window.Use
segmented-controlinstead. - More than five destinations, or a "More" tab, which buries a safety relevant action.Use
menuinstead.
Anatomy
TabBarA nav element inside an overlay Surface, named by label. Neutral chrome only, with no status or category axis.Controlled bylabelTabBar.ItemOne destination. An anchor when href is set, a button otherwise. Floors its target at the 44pt minimum.Controlled byitemsTabBar.ItemIconThe destination's icon, aria-hidden, because the label carries the accessible name.Controlled byitemsTabBar.ItemLabelThe destination's visible words, and the item's accessible name.Controlled byitems
- Surface 1rung="overlay"; supplies the material and the hairline, and takes className
- TabBar 1the nav element, aria-label from label, wrapping a role="list" list
- TabBar.Item 2..5anchor or button; aria-current="page" on the current one
- TabBar.ItemIcon 1aria-hidden
- TabBar.ItemLabel 1
- TabBar.Item 2..5anchor or button; aria-current="page" on the current one
- TabBar 1the nav element, aria-label from label, wrapping a role="list" list
Examples
Four destinations
Four peer destinations, each an icon over a one-word label, with one current.
onValueChange reports the chosen key, which the screen passes back as
value. Read it in greyscale.
tab-bar-four-destinations · base base · style base-lyraOpen under the product themeDestinations as links
An href on each item builds the bar from anchors, so a product router acts on
the navigation. Pinned through className, with safe-area padding clearing the
home indicator.
tab-bar-with-links · base base · style base-lyraOpen under the product themeContent guidelines
One short noun per destination, in sentence case, paired with an icon that reinforces the word rather than replacing it. Do not change a destination's wording or its position between visits. Order from the default section outward, and keep any safety relevant action on a visible surface instead.
Do
"Home", "Trends", "Learn", "Settings" Four peer nouns, ordered from the default screen outward.
Don’t
"More" as a fifth stop, hiding whatever the bar could not fit, including a safety action.
Accessibility
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is author-run, so it is not an independent review, and clinical review is still pending. The rendered pass was clean, with no axe violation, target-size breach, focus gap, or reflow break at 320px, and the source pass found every top risk already handled, so the audit changed nothing here.
Confirmed: a real nav landmark named by label; the current destination marked
three non-colour ways, aria-current="page", a heavier label, and a top
indicator, so no cue rides on colour; every target floored at the 44pt minimum in
rem; and icons aria-hidden with the label as the accessible name.
Recorded residuals: the colour and opacity fades carry no prefers-reduced-motion
guard, which still meets AA since that guard is an AAA item;
a forced-colors repaint may flatten the indicator bar, but font-weight and
aria-current keep the current state legible; and a bottom-fixed bar can obscure
focused content, but this component owns no position, so SC 2.4.11 is the
product's concern. Contrast for the current treatment on the overlay
material is still unmeasured; see below.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus to the next destination | Each destination is its own tab stop, not a roving one. |
| Shift+Tab | Moves focus to the previous destination | Focus steps back one destination at a time, in reverse order. |
| Enter | Activates the focused destination | An anchor follows its href; a button reports the key through onValueChange. |
| Space | Activates a button destination | Native anchor behaviour, so Space does not activate a link destination. |
| Home, End | Not bound | The bar adds no roving-focus keys, so the browser default stands. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from TabBarProps in registry/bases/base/tab-bar.tsx.
value makes this controlled, with no internal selection state, and a value
matching no item renders nothing current and warns in development.
onValueChange is optional, because a bar of links can leave navigation to the
href. label is required: an unnamed navigation landmark is a defect.
Related
- Tabs switch between views sharing one screen's space. If the choice stays inside one screen, it is tabs.
- SegmentedControl sets one parameter of a view that stays put, so nothing navigates.
- Menu is a list of actions opened from a button. A tab bar's destinations are always visible.
SegmentedControl
A row of mutually exclusive options with exactly one chosen at a time, for switching a single view between a small set of windows.
Stepper
Progress through a sequence of steps, drawn as a read-only indicator showing which steps are complete, which one is current, and which are still to come.