Skip to content

alouette

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

alouette-navigation

456 linesSource

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 HeaderNav carries the same semantics as text links on an application bar. SidebarNav lays an application sidebar's destinations out as menu rows grouped under section titles. 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.

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 / NavBarItemTabs / Tab
use forroutes, destinationsviews on the current screen
container rolenavigationtablist
item rolelinktab
selected markeraria-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.

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 grow into the spare width, instead of hugging its destinations. That is what the stacked line of an AppHeader wants (alouette-app-shell/SKILL.md).

An application sidebar with more than a handful of destinations groups them: SidebarNav holds the current value like a NavBar (same value / defaultValue / onValueChange, same per-item onPress for <Link asChild>), SidebarNavSection groups rows under a visible title, which also names the group, and SidebarNavItem is the row: a link with aria-current="page". Leave the first section untitled for the primary destinations.

tsx
import { SidebarNav, SidebarNavItem, SidebarNavSection } from "alouette";

<SidebarNav aria-label="Main" value={pathname} onValueChange={router.push}>
  <SidebarNavSection>
    <SidebarNavItem
      href="/"
      label="Dashboard"
      icon={<SquaresFourRegularIcon />}
    />
  </SidebarNavSection>
  <SidebarNavSection title="Team">
    <SidebarNavItem
      href="/observers"
      label="Observers"
      icon={<UsersRegularIcon />}
    />
  </SidebarNavSection>
</SidebarNav>;

It sits on the sidebar's ground with no track of its own: the current row is the emphasis chip raised off it, the others are soft. Its rows are rounded-sm on purpose, so the menu reads apart from the pill controls around it (selects, buttons). Put it in an AppSidebar (alouette-app-shell/SKILL.md), which scrolls it; a short, flat list of destinations is a vertical NavBar instead.

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

A header that has to fit brand, navigation and session on one line takes HeaderNav + HeaderNavItem (alouette-app-shell/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 HeaderNav when it shares the bar with everything else.

tsx
<HeaderNav aria-label="Main" value={pathname} onValueChange={router.push}>
  <HeaderNavItem href="/home" label="Home" icon={<HouseRegularIcon />} />
  <HeaderNavItem
    href="/inbox"
    label="Inbox"
    aria-label="Inbox, 3 unread"
    badge={<Badge size="sm">3</Badge>}
  />
</HeaderNav>

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, HouseRegularIcon } from "alouette-icons/phosphor-icons/House";

<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 is a navigation landmark holding BreadcrumbItems 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 preventDefaulting 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

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="soft">…</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-inputs/SKILL.md for RadioButtonGroup, the same material with radiogroup semantics; alouette-icons/SKILL.md for importing icon elements.