---
title: "TabBar"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/tab-bar"
source: "https://opsinjs.pensievelabs.org/components/tab-bar.md"
section: "Components"
status: "shipped"
kind: "component"
category: "navigation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["bottom navigation", "nav bar"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="tab-bar"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured for the current-destination treatment against the overlay material, so whether it reads as current in greyscale is unverified.&#x22;,
  &#x22;Nobody has listened to this bar in a screen reader, so whether the landmark, the item count and the current state are announced is unchecked.&#x22;,
  &#x22;Forced colours and prefers-contrast: more are unhandled, and whether the current destination survives a palette that replaces both carriers has not been tested.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="tab-bar" />

## Installation [#installation]

<ComponentInstall name="tab-bar" unbuilt="false" importPath="@/components/ui/tab-bar" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;surface&#x22;]" />

## Usage [#usage]

```tsx
import { TabBar } from "@/components/ui/tab-bar"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Moving between two to five top-level sections of a phone app, where the destinations are peers.&#x22;,
  &#x22;Keeping the whole set of destinations in view, so any section is one tap away.&#x22;,
]"
  avoid="[
  { case: &#x22;Switching between views that live inside one screen and share its space.&#x22;, instead: &#x22;tabs&#x22; },
  { case: &#x22;Setting one parameter of a single view, such as a chart's day or week window.&#x22;, instead: &#x22;segmented-control&#x22; },
  { case: &#x22;More than five destinations, or a \&#x22;More\&#x22; tab, which buries a safety relevant action.&#x22;, instead: &#x22;menu&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="tab-bar"
  parts="[
  {
    name: &#x22;TabBar&#x22;,
    describes: &#x22;A nav element inside an overlay Surface, named by label. Neutral chrome only, with no status or category axis.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;TabBar.Item&#x22;,
    describes: &#x22;One destination. An anchor when href is set, a button otherwise. Floors its target at the 44pt minimum.&#x22;,
    prop: &#x22;items&#x22;,
  },
  {
    name: &#x22;TabBar.ItemIcon&#x22;,
    describes: &#x22;The destination's icon, aria-hidden, because the label carries the accessible name.&#x22;,
    prop: &#x22;items&#x22;,
  },
  {
    name: &#x22;TabBar.ItemLabel&#x22;,
    describes: &#x22;The destination's visible words, and the item's accessible name.&#x22;,
    prop: &#x22;items&#x22;,
  },
]"
/>

<CompositionTree
  name="tab-bar"
  tree="[
  {
    part: &#x22;Surface&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;rung=\&#x22;overlay\&#x22;; supplies the material and the hairline, and takes className&#x22;,
    children: [
      {
        part: &#x22;TabBar&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;the nav element, aria-label from label, wrapping a role=\&#x22;list\&#x22; list&#x22;,
        children: [
          {
            part: &#x22;TabBar.Item&#x22;,
            cardinality: &#x22;2..5&#x22;,
            note: &#x22;anchor or button; aria-current=\&#x22;page\&#x22; on the current one&#x22;,
            children: [
              { part: &#x22;TabBar.ItemIcon&#x22;, cardinality: &#x22;1&#x22;, note: &#x22;aria-hidden&#x22; },
              { part: &#x22;TabBar.ItemLabel&#x22;, cardinality: &#x22;1&#x22; },
            ],
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### Four destinations [#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.

<ComponentPreview name="tab-bar-four-destinations" kind="example" align="start" />

### Destinations as links [#destinations-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.

<ComponentPreview name="tab-bar-with-links" kind="example" align="start" />

## Content guidelines [#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.

<DoDont>
  <DoDont.Do>
    **"Home", "Trends", "Learn", "Settings"** Four peer nouns, ordered from the default screen outward.
  </DoDont.Do>

  <DoDont.Dont>
    **"More"** as a fifth stop, hiding whatever the bar could not fit, including a safety action.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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.

<KeyboardTable
  name="tab-bar"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus to the next destination&#x22;,
    notes: &#x22;Each destination is its own tab stop, not a roving one.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus to the previous destination&#x22;,
    notes: &#x22;Focus steps back one destination at a time, in reverse order.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the focused destination&#x22;,
    notes: &#x22;An anchor follows its href; a button reports the key through onValueChange.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates a button destination&#x22;,
    notes: &#x22;Native anchor behaviour, so Space does not activate a link destination.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Not bound&#x22;,
    notes: &#x22;The bar adds no roving-focus keys, so the browser default stands.&#x22;,
  },
]"
/>

<ContrastReport component="tab-bar" />

## API reference [#api-reference]

<PropsTable name="TabBarProps" />

`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 [#related]

* [Tabs](./tabs.mdx) switch between views sharing one screen's space. If the choice stays inside one screen, it is tabs.
* [SegmentedControl](./segmented-control.mdx) sets one parameter of a view that stays put, so nothing navigates.
* [Menu](./menu.mdx) is a list of actions opened from a button. A tab bar's destinations are always visible.
