Bottom Nav
Mobile bottom navigation — a full-width dock or a floating pill, with real navigation semantics (links + aria-current) or tab semantics (roving tabindex + arrow keys), badges, label modes, emphasized and detached actions, sticky pinning, and RTL.
Playground
Installation
pnpm add @tessinaui/uiUsage
Items are children. Give them hrefs and the bar renders a <nav> landmark
of links with aria-current="page" — the right semantics for app-level
navigation, and the documented default:
import { BottomNav, BottomNavItem } from "@tessinaui/ui";
<BottomNav aria-label="Primary" variant="floating">
<BottomNavItem value="home" href="/" icon={<Home />}>Home</BottomNavItem>
<BottomNavItem value="inbox" href="/inbox" icon={<Inbox />} badge={3}>Inbox</BottomNavItem>
<BottomNavItem value="profile" href="/profile" icon={<User />}>Profile</BottomNavItem>
</BottomNav>Leave the hrefs off and the same bar is a real tablist (Base UI Tabs
underneath: roving tabindex, arrow keys, aria-selected) for switching
panels inside one view. Never mix the two in one bar — the bar warns in
development if you do.
Use 3–5 destinations. Fewer is easier to navigate (Apple HIG), and 3–5 is Material's rule; on wide screens hand off to a sidebar or rail instead of stretching the bar.
Examples
Default
Three link items, floating, sm — the playground's opening state.
Variants
default is the edge-to-edge dock with a top hairline; floating is the
inset pill (translucent by default) with the sliding active chip.
Sizes
Each size lands on a published platform metric: sm 44px (Apple's
touch-target floor), md 56px (Material 2, the default), lg 64px (M3
Expressive, 24px icons). Icons scale automatically — no per-size icon
classes.
Rounded
The radius cascade: items step one inset inside the shell at every value —
at full the shell is 36px and the items 32px, so the pill nests instead of
seaming.
Label visibility
always, selected (only the active item shows its label — Material's
selected mode) or never (icon-only). Labels always stay in the accessible
name, and the bar never changes height. Per-item isLabelHidden overrides
in either direction.
Badges
number → count (capped 99+), true → dot, a short string renders
verbatim. The visual badge is decorative; the value joins the item's
accessible name after the label — "Alerts, 99+ new items". Word it precisely
with badgeLabel.
Router links
render swaps the anchor for a router link (Next.js Link here). Keep
href on the item — it is what switches the bar into nav semantics.
Tabs mode
No hrefs → a real tablist: roving tabindex, Left/Right/Home/End, manual
activation (arrows move focus, Enter/Space selects). For same-view panel
switching only.
Center action
BottomNavAction — an emphasized button between destinations (create,
record, compose). It is an action, not a destination: no value, no
aria-current.
Detached action
detached floats the action beside the pill as its own circle — the
companion-button pattern, and the shape of iOS 26's separated search tab.
Sticky
sticky pins the bar to the bottom of its scroll container while content
passes underneath. The bar's own safe-area reserve keeps it above the iPhone
home indicator.
Translucent
Blur + low-alpha fill so content shows through. The floating pill's default;
the dock opts in with translucent. Falls back to an opaque bar where
backdrop-filter is unsupported and under prefers-reduced-transparency.
Active icon
selectedIcon swaps in while the item is selected — outline at rest, filled
when active, the tab-bar convention Apple codifies. Phosphor's
weight="fill" provides the filled counterpart for free.
Disabled
A disabled nav item drops its href (out of the tab order, no "open in new
tab") and announces aria-disabled. In tabs mode it stays reachable by
arrows but not selectable. Prefer an empty state over disabling a
destination.
Controlled
value + onValueChange — the router or any state owns the selection.
hapticFeedback adds a 10ms vibration tick on change (Android browsers; iOS
Safari has no Vibration API, so it silently no-ops).
Avatar item
icon is any node — an avatar makes the profile destination personal. The
label still names the item for screen readers even icon-only.
Accessory
A mini-player or banner stacked flush above the bar — pure composition, no prop. Apple models the same thing as the tab bar's bottom accessory.
Right to left
dir="rtl" flips layout and, in tabs mode, the arrow-key direction. Badges
anchor to the logical end, so they flip with the script.
Loading
Count-exact placeholders in the real bar frame — same height floor, same safe-area reserve, so nothing jumps when the items arrive.
API Reference
BottomNav
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "floating" | "default" | Edge-to-edge dock, or inset floating pill |
size | "sm" | "md" | "lg" | "md" | Bar scale — 44 / 56 / 64px with 18/20/24px icons |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Floating shell radius; items step one inset tighter |
labelVisibility | "always" | "selected" | "never" | "always" | Label mode. Labels always stay in the accessible name; the bar never changes height |
translucent | boolean | floating: on, dock: off | Blur + low-alpha fill, with opaque fallbacks (backdrop-filter unsupported, prefers-reduced-transparency) |
sticky | boolean | false | Pin to the bottom of the scroll container (sticky bottom-0) |
hapticFeedback | boolean | false | 10ms vibration on selection change. Android browsers only; never on a re-tap |
value | string | — | Controlled active value — drives aria-current (nav) or aria-selected (tabs) |
defaultValue | string | first item | Uncontrolled initial value |
onValueChange | (value: string) => void | — | Called when the active item changes |
dir | "ltr" | "rtl" | — | Un-forced; stamped (and fed to the arrow-key logic) only when set |
aria-label | string | — | Names the landmark / tablist. Required in nav mode (dev-warns without) |
Any item with an href makes the whole bar a <nav> of links; none makes
it a tablist. Mixed bars warn in development and are treated as navigation.
role="tablist" on the bar is the documented opt-out to keep tab semantics
on links.
BottomNavItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Identity — drives selection |
children | ReactNode | — | The label. Names the item even when visually hidden |
icon | ReactNode | — | Optional — omit for a text-only item; any node works (avatar) |
selectedIcon | ReactNode | — | Swapped in while selected; falls back to icon |
href | string | — | Renders a real anchor and switches the bar to nav semantics |
render | ReactElement | — | Custom link element (render={<Link href="/x" />}); it must spread props and forward its ref. Nav mode only |
badge | number | boolean | string | — | Count (99+ cap), dot, or short text. Joins the accessible name |
badgeLabel | string | auto | Screen-reader wording for the badge ("3 unread messages") |
isLabelHidden | boolean | — | Per-item override of the bar's labelVisibility, both directions |
disabled | boolean | false | Nav: drops href + aria-disabled. Tabs: reachable, not selectable |
BottomNavAction
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | The action's icon (the circle shows only this) |
children | ReactNode | — | Visually hidden accessible name (or pass aria-label) |
detached | boolean | false | Float beside the bar as a companion circle. In tabs mode actions always sit beside the bar — a tab list may only own tabs |
Plus every <button> prop — it is a plain button, never a destination.
BottomNavSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "floating" | "default" | Mirrors the bar |
size | "sm" | "md" | "lg" | "md" | Mirrors the bar |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Mirrors the bar |
items | number | 4 | Count-exact placeholders |
isLabelHidden | boolean | false | Icon-only placeholders |
Keyboard Navigation
Nav mode (items with href) — standard link keyboarding:
| Key | Action |
|---|---|
Tab / Shift+Tab | Move through the links — each is its own tab stop |
Enter | Follow the focused link |
Tabs mode (no hrefs) — the ARIA tabs contract via Base UI:
| Key | Action |
|---|---|
Tab | Into the bar (lands on the selected tab), then out |
Left / Right | Move focus between tabs (flipped in RTL), wrapping |
Home / End | First / last tab |
Enter / Space | Select the focused tab (manual activation) |
Accessibility
- Nav mode renders a named
<nav>landmark holding a<ul>of links, witharia-current="page"on the active link — neveraria-selected, which promises a same-view panel a link does not have. - Tabs mode renders a real tablist: roving tabindex, arrow keys,
aria-selected, manual activation. - Badge values join the item's accessible name as visually hidden text
appended after the label ("Inbox, 3 new items") — the visual badge is
aria-hidden.badgeLabelcontrols the wording. - Hidden labels stay in the accessible name; the bar never trades its ≥44px touch targets for a label mode.
- The sliding indicator moves without animating under
prefers-reduced-motion; translucency turns opaque underprefers-reduced-transparency. - Every item shows a visible focus ring via
focus-visible.
Migration from the array API
TES-59 rebuilt the component; three renames and one structural change:
// Before
<BottomNav type="floating" isLabelHidden items={[
{ value: "home", label: "Home", icon: <Home />, badge: 3 },
]} />
// After
<BottomNav variant="floating" labelVisibility="never" aria-label="Primary">
<BottomNavItem value="home" href="/" icon={<Home />} badge={3}>Home</BottomNavItem>
</BottomNav>| Before | After |
|---|---|
items={[…]} | <BottomNavItem> children (label → children, per-item props unchanged) |
type | variant |
isLabelHidden | labelVisibility="never" (per-item isLabelHidden unchanged) |
BottomNavItemDef | removed — props live on BottomNavItem |
implicit role="tablist" | nav landmark when items carry href; tablist (now with real keyboarding) when they don't |