alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
419 lines (327 loc) • 14.4 kB
Markdown
---
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, and AppHeaderNav carries the same
semantics as text links on an application bar. 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
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/layout/AppHeaderNav.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/layout/AppHeaderNavItem.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).
### Navigation on an application bar
A header that has to fit brand, navigation and session on one line takes
`AppHeaderNav` + `AppHeaderNavItem` (alouette-layout/SKILL.md) instead: the same
`navigation` / `link` / `aria-current="page"` semantics and the same
`href`-as-identity, over a lighter material — text destinations on the bar
itself, the current one underlined in the group's accent, no track and no chip.
It also takes a `badge` after the label, which the segmented item has no room
for. Reach for `NavBar` when the navigation is the screen's main control (the
stacked line of a header, a sidebar rail), and for `AppHeaderNav` when it shares
the bar with everything else.
```tsx
<AppHeaderNav aria-label="Main" value={pathname} onValueChange={router.push}>
<AppHeaderNavItem href="/home" label="Home" icon={<HouseRegularIcon />} />
<AppHeaderNavItem
href="/inbox"
label="Inbox"
aria-label="Inbox, 3 unread"
badge={<Badge size="sm">3</Badge>}
/>
</AppHeaderNav>
```
### 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>
<Box className="surface" role="tabpanel" id="panel-week" aria-labelledby="tab-week">…</Box>
```
### 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
<Box className="surface lowered flex-row">
<PressableBox variant="ghost">…</PressableBox>
</Box>
```
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.