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.
pnpm add @tessinaui/uiWhen to use
- The top-level destinations of an app on a phone. Three to five places a person moves between all day, each its own screen, reachable from anywhere.
- Destinations of similar weight. Every item is a place to go, not a thing
to do; give each an
hrefso the bar is real navigation witharia-current. - A bar that stays in view. Keeping the bar visible tells people which area
they are in. Pin it with
sticky, or lethideOnScrolltuck it away while content scrolls down. - Switching panels inside one view. Leave the
hrefs off for tabs mode, the same bar as a real tablist. - Short labels. One word per destination. Hidden labels stay in the accessible name, and the bar keeps its height in every label mode.
When not to use
- For actions on the current screen. Edit, share and delete belong in a
Toolbar. A bar of actions is not navigation, and
announcing it as navigation misleads. One emphasised action between
destinations is the exception: use
BottomNavAction. - For panels in one section when the row is not pinned to the bottom. Use Tabs.
- On a wide screen. Move the destinations into a Sidebar rather than stretching the bar.
- For one choice from a small fixed set, as an input. Use a Segmented Control.
- For fewer than three or more than five destinations. Past five the targets get too narrow for a thumb; group the rest behind one destination or a sidebar.
- Mixing links and tabs in one bar. Pick one; the bar warns in development if you mix them.
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, and past five the targets get too narrow for a thumb; 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 real touch metric: sm 44px (the touch-target floor),
md 56px (the default — the floor plus room for a label under the icon),
lg 64px (the roomy bar, 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) 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, for an action such as search that sits apart from
the destinations.
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.
Hide on scroll
hideOnScroll slides the bar below the edge while the content scrolls down, and
brings it back when the content scrolls up or nears the top. It listens to the
window, and to any scroller marked data-hide-nav-scroll. The bar clears its own
height plus however far it rests above the nearest clipping edge, so a floating
bar in a padded container needs no offset.
- Place the bar outside the content it hides over, in a
fixedcontainer or absolutely over the scroller. Astickybar at the end of the content adds its own height to the scroll range while hidden. - Focus inside the bar keeps it shown, so keyboard users never tab into a hidden bar. It holds still while a finger is down, so a tap never lands on a bar that is moving away.
- Under
prefers-reduced-motionit jumps instead of sliding. The bar stays in the accessibility tree while hidden.
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, under prefers-reduced-transparency, and
inside a coloured <Surface> (On color).
On color
Inside a coloured Surface the bar needs no prop. Both variants paint their own page surface, so items, labels and badges keep the page's inks. The floating glass turns opaque there: over a flat plate it would be mostly the plate, and the focus rings on the bar would lose their ground. A detached action stands on the plate itself, so it rings in the plate's ink over a gap of the plate.
Active icon
selectedIcon swaps in while the item is selected — outline at rest, filled
when active, the long-standing tab-bar convention. Any icon set that ships a
filled weight gives you the 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. It is the familiar bottom-accessory shape, built from what you already have.
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, a coloured <Surface>) |
sticky | boolean | false | Pin to the bottom of the scroll container (sticky bottom-0) |
hideOnScroll | boolean | false | Slide below the edge scrolling down and return scrolling up; see Hide on scroll |
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" | inherited | Writing direction, arrow keys included. Unset, the bar follows the page; stamped 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
VoiceOver + Safari: passed 2026-09-14. The bar reads "Primary, navigation", "list 3 items", then "current page, link, Home", "link, Search", "link, Alerts". NVDA and JAWS are untested.
- 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 lands with a spring (
ease-indicator: a 1.5% overshoot, then settle). The floating bar clips at its own outline, so on a wide bar the chip presses into the end instead of sliding past it; content that overflows the floating bar is clipped too. Underprefers-reduced-motionthe chip moves without animating; translucency turns opaque underprefers-reduced-transparency. - Every item shows a visible focus ring via
focus-visible. The dock draws it inside the item, on the bar's own fill, because its items are flush with the bar's edges. On a coloured<Surface>a detached action rings in the plate's ink over a gap of the plate.
Migration from the array API
The items array became composed children; 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 |