opsinjs
ComponentsNavigation

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

Theme
Density
Text
Status
tab-bar · base base · style base-lyraOpen under the product theme

Installation

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 tabs instead.
  • Setting one parameter of a single view, such as a chart's day or week window.Use segmented-control instead.
  • More than five destinations, or a "More" tab, which buries a safety relevant action.Use menu instead.

Anatomy

  1. TabBarA nav element inside an overlay Surface, named by label. Neutral chrome only, with no status or category axis.Controlled by label
  2. TabBar.ItemOne destination. An anchor when href is set, a button otherwise. Floors its target at the 44pt minimum.Controlled by items
  3. TabBar.ItemIconThe destination's icon, aria-hidden, because the label carries the accessible name.Controlled by items
  4. TabBar.ItemLabelThe destination's visible words, and the item's accessible name.Controlled by items
  • 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

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.

Theme
Density
Text
Status
tab-bar-four-destinations · base base · style base-lyraOpen under the product theme

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.

Theme
Density
Text
Status
tab-bar-with-links · base base · style base-lyraOpen under the product theme

Content 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.

KeyActionNotes
TabMoves focus to the next destinationEach destination is its own tab stop, not a roving one.
Shift+TabMoves focus to the previous destinationFocus steps back one destination at a time, in reverse order.
EnterActivates the focused destinationAn anchor follows its href; a button reports the key through onValueChange.
SpaceActivates a button destinationNative anchor behaviour, so Space does not activate a link destination.
Home, EndNot boundThe bar adds no roving-focus keys, so the browser default stands.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor
body text on the canvas materiallight104.717.96:1Pass
body text on the canvas materialdark-100.518.00:1Pass
body text on the inset materiallight98.116.32:1Pass
body text on the inset materialdark-100.117.18:1Pass
body text on the card materiallight104.717.96:1Pass
body text on the card materialdark-99.616.32:1Pass
body text on the raised materiallight104.717.96:1Pass
body text on the raised materialdark-97.513.77:1Pass
body text on the sheet material over the darkest backdroplight99.916.76:1Pass
body text on the sheet material over the darkest backdropdark-99.716.47:1Pass
body text on the sheet material over the lightest backdroplight104.717.96:1Pass
body text on the sheet material over the lightest backdropdark-80.86.07:1Pass
body text on the overlay material over the darkest backdroplight85.913.48:1Pass
body text on the overlay material over the darkest backdropdark-99.916.77:1Pass
body text on the overlay material over the lightest backdroplight104.717.96:1Pass
body text on the overlay material over the lightest backdropdark-54.22.69:1Below 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.

  • 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.

On this page