DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

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

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)
stickybooleanfalsePin to the bottom of the scroll container (sticky bottom-0)
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"—Un-forced; stamped (and fed to the arrow-key logic) 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

  • 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 moves without animating under prefers-reduced-motion; translucency turns opaque under prefers-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>
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
Bar ChartBreadcrumb

On this page

PlaygroundInstallationUsageExamplesDefaultVariantsSizesRoundedLabel visibilityBadgesRouter linksTabs modeCenter actionDetached actionStickyTranslucentActive iconDisabledControlledAvatar itemAccessoryRight to leftLoadingAPI ReferenceBottomNavBottomNavItemBottomNavActionBottomNavSkeletonKeyboard NavigationAccessibilityMigration from the array API