Bottom navigation

Free · MIT

Mobile bottom bar where a pill glides to the selected tab and its icon bounces, in a labelled or expanding variant, with popping badges and an optional raised center action.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/bottom-nav.json

The file lands in components/goodui/bottom-nav.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/bottom-nav works too.

Usage

import { CalendarDays, Home, Inbox, Plus, Users } from "lucide-react"
import { BottomNav } from "@/components/goodui/bottom-nav"

export function AppTabBar({ tab, onTab, unread, onNewBooking }: { tab: string; onTab: (v: string) => void; unread: number; onNewBooking: () => void }) {
  return (
    <BottomNav
      aria-label="Salon app"
      className="fixed inset-x-0 bottom-0 z-40"
      value={tab}
      onValueChange={onTab}
      items={[
        { value: "today", label: "Today", icon: Home },
        { value: "calendar", label: "Calendar", icon: CalendarDays },
        { value: "clients", label: "Clients", icon: Users },
        { value: "inbox", label: "Inbox", icon: Inbox, badge: unread },
      ]}
      centerAction={{ label: "New booking", icon: Plus, onClick: onNewBooking }}
    />
  )
}

When to use

Use it for
  • Primary navigation in a mobile web app or PWA with 3–5 top-level sections (Today, Calendar, Clients, Inbox).
  • When one action deserves a raised button in the middle of the bar, such as "New booking".
  • Showing unread counts or a "new" dot on sections without opening them.
Reach for something else
  • Desktop layouts: use a sidebar or top navigation; a bottom bar wastes space on wide screens.
  • Switching views inside a page or card: use tabs-indicator or segmented-control.
  • More than five destinations: move the rest into a "More" screen or a menu.
If you needUse
Labels always visible (most apps)variant="pill" (default)
Compact bar, label only on the active tabvariant="expand"
One primary create actioncenterAction={{ label, icon, onClick }}
Unread countbadge: 3 on the item (99+ above 99)
Something new, no countbadge: true

Examples

Expanding variant driven by the router

const pathname = usePathname()
const router = useRouter()

<BottomNav
  variant="expand"
  value={pathname.split("/")[1] || "today"}
  onValueChange={(v) => router.push(`/${v}`)}
  items={items}
/>

API reference

PropTypeDefault
items*
The tabs, left to right. With centerAction, the first half goes before it.
BottomNavItem[]—
value
Selected tab value (controlled), e.g. derived from the current route.
string—
defaultValue
Initially selected tab when uncontrolled; defaults to the first item.
string—
onValueChange
Called with the tab's value when it is tapped.
(value: string) => void—
variant
`pill`: pill behind the icon with labels always shown. `expand`: the active tab grows to show its label.
"pill" | "expand""pill"
centerAction
Raised round button in the middle of the bar, e.g. "New booking".
{ label: string; icon: React.ComponentType<{ className?: string }>; onClick?: () => void }—
labels
Text for built-in controls and screen readers; pass translations here.
Partial<BottomNavLabels>—

Also accepts every <nav> attribute.

Accessibility

TabMoves through the tabs and the center action (every button is a tab stop).
ArrowRight / ArrowLeftMoves focus to the next or previous tab, wrapping around; it does not select it. Mirrored under dir="rtl".
Home / EndMoves focus to the first or last tab.
Enter / SpaceSelects the focused tab or runs the center action (native <button>).
  • Renders a <nav> labelled by aria-label (default "Main"); the selected tab has aria-current="page".
  • Each tab's accessible name is its visible label followed by sr-only ", 3 new" or ", new" when it has a badge (translate with labels.badgeCount / labels.badgeDot); the badge bubble itself is aria-hidden.
  • The center action is a <button> named by centerAction.label.
  • The bar adds bottom padding for env(safe-area-inset-bottom), so it clears the iPhone home indicator.

Motion

  • A tinted pill glides to the selected tab through a shared layout animation (spring 480 / 36, mass 0.9), and the chosen icon hops up 5px while scaling to 1.18 and back in 0.45 s.
  • Expand variant: tabs resize with layout animation on the same spring and the active label fades in from 4px aside after 60 ms.
  • Badges pop in from 0.3 scale on a spring (stiffness 600, damping 20) and pop again when the count changes.
  • The center action lifts 2px on hover, presses to 0.9 and its icon turns 90° on hover (300 ms).
  • Reduced motion: the pill and tabs move instantly, no hop, no badge pop, no lift, press or icon turn.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • The component is not fixed-positioned; add className="fixed inset-x-0 bottom-0 z-40" (and bottom padding on your page) yourself.
  • Keep items to 3–5. With centerAction, the first Math.ceil(n / 2) items go left of it, so use an even count for a balanced bar.
  • icon takes the component (icon: Home), not an element.
  • For route-based navigation, derive value from the pathname and navigate in onValueChange; the tabs are buttons, not links.
  • badge accepts a number or true; 0, false or undefined hides it.
  • labels={{ badgeCount: (n) => `, ${n} nuevos`, badgeDot: ", nuevo" }} translates the screen-reader badge text.

FAQ

Are the tabs links?

No, they are buttons that call onValueChange. Navigate with your router there, and set value from the current route so the pill follows back/forward navigation.

Does the pill jump between two bars on the same page?

No. Each bar has its own LayoutGroup and layoutId from useId.

Related