DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
BreadcrumbTabsPaginationStepperMenu ItemToolbarMenubarNavigation MenuAccordionCollapsibleCommandBottom NavTop Header MobileTop Header DesktopSidebar
AI agentsContributing
  1. Components
  2. Bottom Nav

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/ui

When 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 href so the bar is real navigation with aria-current.
  • A bar that stays in view. Keeping the bar visible tells people which area they are in. Pin it with sticky, or let hideOnScroll tuck 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

Terminal
pnpm add @tessinaui/ui

Usage

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:

example.tsx
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 fixed container or absolutely over the scroller. A sticky bar 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-motion it 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

PropTypeDefaultDescription
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
translucentbooleanfloating: on, dock: offBlur + low-alpha fill, with opaque fallbacks (backdrop-filter unsupported, prefers-reduced-transparency, a coloured <Surface>)
stickybooleanfalsePin to the bottom of the scroll container (sticky bottom-0)
hideOnScrollbooleanfalseSlide below the edge scrolling down and return scrolling up; see Hide on scroll
hapticFeedbackbooleanfalse10ms vibration on selection change. Android browsers only; never on a re-tap
valuestring—Controlled active value — drives aria-current (nav) or aria-selected (tabs)
defaultValuestringfirst itemUncontrolled initial value
onValueChange(value: string) => void—Called when the active item changes
dir"ltr" | "rtl"inheritedWriting direction, arrow keys included. Unset, the bar follows the page; stamped only when set
aria-labelstring—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

PropTypeDefaultDescription
valuestring—Identity — drives selection
childrenReactNode—The label. Names the item even when visually hidden
iconReactNode—Optional — omit for a text-only item; any node works (avatar)
selectedIconReactNode—Swapped in while selected; falls back to icon
hrefstring—Renders a real anchor and switches the bar to nav semantics
renderReactElement—Custom link element (render={<Link href="/x" />}); it must spread props and forward its ref. Nav mode only
badgenumber | boolean | string—Count (99+ cap), dot, or short text. Joins the accessible name
badgeLabelstringautoScreen-reader wording for the badge ("3 unread messages")
isLabelHiddenboolean—Per-item override of the bar's labelVisibility, both directions
disabledbooleanfalseNav: drops href + aria-disabled. Tabs: reachable, not selectable

BottomNavAction

PropTypeDefaultDescription
iconReactNode—The action's icon (the circle shows only this)
childrenReactNode—Visually hidden accessible name (or pass aria-label)
detachedbooleanfalseFloat 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

PropTypeDefaultDescription
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
itemsnumber4Count-exact placeholders
isLabelHiddenbooleanfalseIcon-only placeholders

Keyboard Navigation

Nav mode (items with href) — standard link keyboarding:

KeyAction
Tab / Shift+TabMove through the links — each is its own tab stop
EnterFollow the focused link

Tabs mode (no hrefs) — the ARIA tabs contract via Base UI:

KeyAction
TabInto the bar (lands on the selected tab), then out
Left / RightMove focus between tabs (flipped in RTL), wrapping
Home / EndFirst / last tab
Enter / SpaceSelect 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, with aria-current="page" on the active link — never aria-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. badgeLabel controls 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. Under prefers-reduced-motion the chip moves without animating; translucency turns opaque under prefers-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:

example.tsx
// 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>
BeforeAfter
items={[…]}<BottomNavItem> children (label → children, per-item props unchanged)
typevariant
isLabelHiddenlabelVisibility="never" (per-item isLabelHidden unchanged)
BottomNavItemDefremoved — props live on BottomNavItem
implicit role="tablist"nav landmark when items carry href; tablist (now with real keyboarding) when they don't
CommandTop Header Mobile

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsSizesRoundedLabel visibilityBadgesRouter linksTabs modeCenter actionDetached actionStickyHide on scrollTranslucentOn colorActive iconDisabledControlledAvatar itemAccessoryRight to leftLoadingAPI ReferenceBottomNavBottomNavItemBottomNavActionBottomNavSkeletonKeyboard NavigationAccessibilityMigration from the array API