---
name: alouette-navigation
description: >
  Move the user between places. NavBar and NavBarItem navigate between
  destinations — each item is a real link announced as the current page, and
  composes with expo Router; Tabs and Tab switch between views of one screen,
  announced as a tab list. Both are built by composing their items, over the
  same segmented bar, and both can shrink to a pill of icon-only chips; a NavBar
  can also stand vertically as a sidebar rail. Breadcrumbs and BreadcrumbItem
  render the trail back through the ancestors of the current page. Pick by
  meaning, not by looks: navigation is never a RadioButtonGroup, which announces
  a form value, and never a Link wrapped around a Text, which has no interactive
  state. Load when building a tab bar, a section switcher, a breadcrumb trail,
  or navigation between routes.
type: core
library: alouette
library_version: "22.11.0"
requires:
  - alouette-theming
  - alouette-actions
sources:
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/NavBar.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/NavBarItem.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Tabs.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Tab.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/selection/SelectionContext.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/selection/SegmentedBar.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/selection/SegmentedItem.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Breadcrumbs.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/BreadcrumbItem.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/NavBar.stories.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Tabs.stories.tsx"
  - "christophehurpeau/alouette:packages/alouette/src/ui/navigation/Breadcrumbs.stories.tsx"
---

This skill builds on alouette-theming. Read it first for the accent model.

# alouette — Navigation

Two segmented groups over one shared base (`src/ui/selection/`): a lowered 44px
bar whose selected item raises a chip. `NavBar` navigates between destinations,
`Tabs` switches views on the same screen. They differ only in accessibility
semantics — pick by what the press does, not by how it looks.

|                 | `NavBar` / `NavBarItem` | `Tabs` / `Tab`              |
| --------------- | ----------------------- | --------------------------- |
| use for         | routes, destinations    | views on the current screen |
| container role  | `navigation`            | `tablist`                   |
| item role       | `link`                  | `tab`                       |
| selected marker | `aria-current="page"`   | `aria-selected`             |

## Setup

```tsx
import { Tabs, Tab } from "alouette";

<Tabs aria-label="Period" defaultValue="week" onValueChange={setPeriod}>
  <Tab value="day" label="Day" />
  <Tab value="week" label="Week" />
  <Tab value="month" label="Month" />
</Tabs>;
```

## Core Patterns

### Controlled vs uncontrolled

Both groups own the value: `defaultValue` for uncontrolled, `value` +
`onValueChange` for controlled. A `NavBar` backed by a router is controlled — its
value is the current route, matched against each item's `href`.

```tsx
<NavBar
  aria-label="Main"
  value={pathname}
  onValueChange={(href) => router.push(href)}
>
  <NavBarItem href="/home" label="Home" />
  <NavBarItem href="/reports" label="Business Reports" />
</NavBar>
```

`NavBarItem` has no `value`: `href` is its identity. On web it renders a real
`<a href>` (native ignores it), and the item cancels the browser's own
navigation — routing stays the app's job, through `onValueChange` or `onPress`.
A disabled item drops its `href`, since a disabled pressable never sees the
press that would cancel it.

### Per-item onPress, and expo Router links

An item may navigate itself; its `onPress` then replaces the group's
`onValueChange` for that item. The group must be controlled in that case — its
internal value is never updated. A custom handler that routes must call
`event.preventDefault()` on web, or the anchor reloads the page under it.

`<Link asChild>` injects exactly those two props (`href` and a `preventDefault`
ing `onPress`), so it composes without repeating the route:

```tsx
import { Link } from "expo-router";

<NavBar aria-label="Main" value={pathname}>
  <Link href="/home" asChild>
    <NavBarItem label="Home" />
  </Link>
</NavBar>;
```

### Vertical rail

`NavBar` takes `orientation="vertical"`: the same lowered track, stacked. Each
item keeps its 44px tap target and its chip spans the bar's width instead of
shrinking to its label. The bar is content-width — give it a `className` width
for a fixed rail. `Tabs` and `RadioButtonGroup` stay horizontal.

```tsx
<NavBar
  aria-label="Main"
  className="w-[220px]"
  orientation="vertical"
  value={pathname}
  onValueChange={router.push}
>
  <NavBarItem href="/home" label="Home" icon={<HouseRegularIcon />} />
  <NavBarItem href="/reports" label="Business Reports" />
</NavBar>
```

`stretch` is the horizontal counterpart: the bar fills the width it is given and
its items share it equally, instead of hugging its destinations. That is what the
stacked line of an `AppHeader` wants (alouette-layout/SKILL.md).

### Icon-only pill

`variant="icon"` (on `NavBar`, `Tabs` and `RadioButtonGroup` alike) turns the bar
into a pill of square icon-only chips. The item renders its `icon` alone and
`label` stays its accessible name — so `label` is still required and
`getByRole(…, { name })` keeps working, and an item without an `icon` renders an
empty chip.

```tsx
<Tabs aria-label="View" variant="icon" defaultValue="list">
  <Tab value="list" label="List" icon={<ListRegularIcon />} />
  <Tab value="grid" label="Grid" icon={<SquaresFourRegularIcon />} />
</Tabs>
```

### Leading icon

`icon` takes a rendered icon element and is auto-sized and auto-tinted from the
item's selected/disabled state. `activeIcon` — typically the duotone twin of the
same glyph — replaces it while the item is hovered, focused or pressed, and for
as long as the item is selected: the current page in a `NavBar`, the selected
`Tab`, the checked `RadioButton`. A disabled item never swaps.

```tsx
import { HouseDuotoneIcon } from "alouette-icons/phosphor-icons/HouseDuotoneIcon";
import { HouseRegularIcon } from "alouette-icons/phosphor-icons/HouseRegularIcon";

<NavBarItem href="/home" label="Home" icon={<HouseRegularIcon />} />

<NavBarItem
  href="/home"
  label="Home"
  icon={<HouseRegularIcon />}
  activeIcon={<HouseDuotoneIcon />}
/>;
```

`activeAccent` tints `activeIcon` with an accent of its own, so the glyph changes
color as well as weight. It is on `NavBarItem`, `Tab` and `RadioButton` only —
`Button`, `IconButton` and `MenuItem` take `activeIcon` but not `activeAccent`.

```tsx
<NavBarItem
  href="/archive"
  label="Archive"
  icon={<TrashRegularIcon />}
  activeIcon={<TrashDuotoneIcon />}
  activeAccent="danger"
/>
```

### Accent and disabled

`accent` themes the whole bar (the group wraps itself in the accent theme);
`disabled` on the group disables every item, `disabled` on an item disables just
that one.

```tsx
<Tabs aria-label="Period" accent="brand" defaultValue="week">
  <Tab value="week" label="Week" />
  <Tab disabled value="month" label="Month" />
</Tabs>
```

### Wiring tab panels

`Tab` passes `id` and `aria-controls` through; render the panel yourself and
point it back at the tab. `Tabs` renders no panel.

```tsx
<Tabs aria-label="Ranges" value={range} onValueChange={setRange}>
  <Tab id="tab-week" aria-controls="panel-week" value="week" label="Week" />
</Tabs>
<Surface role="tabpanel" id="panel-week" aria-labelledby="tab-week">…</Surface>
```

### Breadcrumbs — the trail to the current page

`Breadcrumbs` is a `navigation` landmark holding `BreadcrumbItem`s from the root
down to the page being viewed. It is not a segmented bar: it has no ground of its
own, wraps on a narrow screen, and separates its crumbs with a caret (`separator`
takes another icon element). Every crumb but the last is a `LinkText`; the last
one is the current page, rendered as plain text carrying `aria-current="page"`.

```tsx
import { BreadcrumbItem, Breadcrumbs } from "alouette";

<Breadcrumbs onNavigate={router.push}>
  <BreadcrumbItem href="/" label="Home" icon={<HouseRegularIcon />} />
  <BreadcrumbItem href="/reports" label="Reports" />
  <BreadcrumbItem href="/reports/q3" label="Q3" />
</Breadcrumbs>;
```

`onNavigate` receives the pressed crumb's `href` and cancels the anchor's own
navigation — routing stays the app's job. Without it (and without an item
`onPress`) the `<a>` navigates on web and native does nothing. `<Link asChild>`
composes here too, injecting the `href` and a `preventDefault`ing `onPress`. Give
the last crumb its own `href` anyway: the trail decides which one is current, by
position.

## Common Mistakes

### HIGH Passing an options array instead of children

Wrong:

```tsx
<Tabs options={[{ value: "day", label: "Day" }]} />
```

Correct:

```tsx
<Tabs aria-label="Period" defaultValue="day">
  <Tab value="day" label="Day" />
</Tabs>
```

These are compose-children groups, like `RadioButtonGroup`. There is no
`options` prop; each child reads the selected value from the group's context.

Source: packages/alouette/src/ui/navigation/Tabs.tsx

### HIGH Using Tabs for route navigation (or NavBar for in-page views)

Wrong:

```tsx
<Tabs value={pathname} onValueChange={router.push}>
  …
</Tabs>
```

Correct:

```tsx
<NavBar aria-label="Main" value={pathname} onValueChange={router.push}>
  …
</NavBar>
```

The two render the same material but expose different semantics: `tab` promises
a panel on the same screen, `link` + `aria-current="page"` promises a
destination. Assistive tech announces them differently.

Source: packages/alouette/src/ui/navigation/NavBar.tsx; ui/navigation/Tabs.tsx

### HIGH Navigating with a RadioButtonGroup, or a Link wrapped around a Text

Wrong:

```tsx
<RadioButtonGroup value={pathname} onValueChange={router.push}>
  <RadioButton value="/home" label="Home" />
</RadioButtonGroup>

<Link href="/reports">
  <Text className="text-accent">Go to reports</Text>
</Link>
```

Correct:

```tsx
<NavBar aria-label="Main" value={pathname}>
  <Link href="/home" asChild>
    <NavBarItem label="Home" />
  </Link>
  <Link href="/reports" asChild>
    <NavBarItem label="Business Reports" />
  </Link>
</NavBar>
```

`RadioButtonGroup` renders the same bar, but it announces a form control:
`radiogroup` + `radio` + `aria-checked` promises a value being edited, not a
destination, and it emits no anchor on web. A `Link` around a `Text` is the
wrapper mistake from alouette-styling — the text gets no `interactive-*` state,
no focus-visible outline and no 44px target. Both cases are a `NavBar`; a
vertical list of destinations is `orientation="vertical"`, not a
`RadioButtonGroup`.

Source: packages/alouette/src/ui/navigation/NavBar.tsx; ui/inputs/RadioButtonGroup.tsx

### MEDIUM Item onPress on an uncontrolled group

Wrong:

```tsx
<NavBar aria-label="Main" defaultValue="/home">
  <NavBarItem href="/settings" label="Settings" onPress={handlePress} />
</NavBar>
```

Correct:

```tsx
<NavBar aria-label="Main" value={pathname}>
  <NavBarItem href="/settings" label="Settings" onPress={handlePress} />
</NavBar>
```

`onPress` replaces the group's selection callback, so the uncontrolled internal
value stays where it was and the bar never moves its chip.

Source: packages/alouette/src/ui/navigation/NavBarItem.tsx

### MEDIUM Navigating from an item onPress without preventDefault

Wrong:

```tsx
<NavBarItem
  href="/settings"
  label="Settings"
  onPress={() => router.push("/settings")}
/>
```

Correct:

```tsx
<Link href="/settings" asChild>
  <NavBarItem label="Settings" />
</Link>
```

An item with an `href` is a real anchor on web, so a handler that routes in JS
must cancel the browser default or the page reloads on top of the JS
navigation. The built-in handler does it; a custom one must too.

Source: packages/alouette/src/ui/navigation/NavBarItem.tsx

### MEDIUM Hand-rolling the bar with PressableBox

Wrong:

```tsx
<Surface variant="lowered" className="flex-row">
  <PressableBox variant="ghost">…</PressableBox>
</Surface>
```

Correct: use `NavBar` / `Tabs`. The shared `SegmentedBar` / `SegmentedItem`
already give the 44px tap target inside a 44px bar, the chip cross-fade, the
`focus-visible` outline and the hover/active border — a hand-rolled bar drifts
from all four.

Source: packages/alouette/src/ui/selection/SegmentedBar.tsx; ui/selection/SegmentedItem.tsx

### LOW Omitting aria-label on the group

`NavBar` renders a navigation landmark and `Tabs` a tab list; both should be
named, especially when a screen has more than one.

Source: packages/alouette/src/ui/navigation/NavBar.tsx

See also: alouette-forms/SKILL.md for `RadioButtonGroup`, the same material with
`radiogroup` semantics; alouette-icons/SKILL.md for importing icon elements.
