Component · Navigation & Feedback
Bottom Nav
Overview
A compact tab bar — sometimes called bottom navigation — for an app or mobile site's 3–5 primary
destinations: an icon and a short label per item, fixed to the bottom of the viewport, shown only
below the md breakpoint (768px).
Use it for a mobile/app-style product's primary navigation, reachable with a thumb at the bottom of the screen. Reach for something else when the job is different:
- Top-of-page navigation with a brand, links, and actions, at every viewport width → Navbar.
- The persistent left-hand rail of an already-entered desktop/tablet app → Sidebar Nav.
- Showing where the current page sits in a hierarchy → Breadcrumb.
- Switching between views of the same page → Tabs.
Bottom Nav is a mobile-only surface — it is not a smaller Navbar and does not attempt to also work as one; a product that needs both mobile and desktop primary navigation pairs Bottom Nav with Navbar or Sidebar Nav for wider viewports (see Responsive Behavior below).
Anatomy
BottomNav (the <nav> landmark, fixed to the bottom of the viewport) → BottomNavItem (one
destination: a leading icon stacked above a short label, with an optional indicator anchored at the
icon's upper-right corner) → BottomNavDot (the plain no-count indicator, an alternative to passing
a Badge into the same slot).
BottomNav— the<nav>landmark, fixed to the bottom of the viewport.BottomNavItem— one destination: a leading icon stacked above a short label, with an optional indicator at the icon’s upper-right corner.BottomNavDot— the plain no-count indicator; an alternative to passing aBadgeinto the same slot.
Compound parts, shadcn-style — the caller supplies each destination's icon, label, href, and
which one is current; the component supplies structure, the responsive contract, and styling. No
asChild: BottomNavItem renders a native <a>; for a client-side router, apply the exported
bottomNavItemVariants({ current }) class string to the router's own link element directly
(button.md's escape hatch, the same call Navbar and
Sidebar Nav make).
Live Example
Page content
Scrolls independently behind the bar. Give a real page’s scrollable region bottom padding at least the bar’s height (plus the safe-area inset) so content never ends up hidden underneath it.
This demo frame overrides Bottom Nav’s real fixed-position and md:hidden classes so it stays pinned inside the box at every viewport width — in real usage leave both alone; the bar is fixed to the actual viewport and hidden at the md breakpoint (768px) and up. “Alerts” shows the optional badge slot holding a real Badge component for a count; “Profile” shows the same slot holding BottomNavDot, the plain no-count indicator (paired here with visually-hidden “New activity” text). Both are caller-supplied nodes, not a built-in count/notification system.
Code Example
"use client";
import type { MouseEvent } from "react";
import { useState } from "react";
import { Bell, Compass, Home, Search, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { BottomNav, BottomNavDot, BottomNavItem } from "@/components/ui/bottom-nav";
/**
* Live Bottom Nav example. `"use client"` only so the demo can swallow real
* navigation and drive which item is `current` — the parts themselves are
* server components.
*
* Bottom Nav's real responsive contract is `fixed` to the actual browser
* viewport and `md:hidden` (bottom-nav.md#responsive-contract) — the same
* true-viewport-media-query approach Navbar and Sidebar Nav's own responsive
* examples use, not a container query. To keep the live example visible
* inside this docs page at every width instead of requiring an actual phone
* or a resized browser, the preview frame below overrides those two classes
* (`className="absolute md:flex"`, pinning the bar to the frame's own bottom
* edge instead of the viewport's) — a demo-only override, spelled out below
* the frame. Real usage leaves both alone.
*
* "Alerts" and "Profile" demonstrate the two supported `badge` treatments
* (bottom-nav.md#variants): a count `Badge`, and the plain-dot
* `BottomNavDot` for "new activity" with no number — paired with
* visually-hidden text since a bare dot carries no accessible name on its
* own.
*/
const ITEMS = [
{ href: "/home", label: "Home", icon: <Home /> },
{ href: "/explore", label: "Explore", icon: <Compass /> },
{ href: "/search", label: "Search", icon: <Search /> },
{
href: "/alerts",
label: "Alerts",
icon: <Bell />,
badge: <Badge variant="destructive">3</Badge>,
},
{
href: "/profile",
label: "Profile",
icon: <User />,
badge: (
<>
<BottomNavDot />
<span className="sr-only">New activity</span>
</>
),
},
];
const stayOnPage = (event: MouseEvent<HTMLAnchorElement>) =>
event.preventDefault();
export function BottomNavShowcase() {
const [current, setCurrent] = useState(ITEMS[0].href);
return (
<div className="flex flex-col gap-3">
<div className="relative mx-auto h-80 w-full max-w-xs overflow-hidden rounded-md border border-solid border-border bg-background">
<div className="flex h-full flex-col gap-2 overflow-y-auto p-4 pb-20">
<p className="text-sm font-medium leading-snug">Page content</p>
<p className="text-xs leading-snug text-muted-foreground">
Scrolls independently behind the bar. Give a real page’s
scrollable region bottom padding at least the bar’s height (plus
the safe-area inset) so content never ends up hidden underneath
it.
</p>
</div>
<BottomNav className="absolute md:flex">
{ITEMS.map((item) => (
<BottomNavItem
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
badge={item.badge}
current={item.href === current}
onClick={(event) => {
stayOnPage(event);
setCurrent(item.href);
}}
/>
))}
</BottomNav>
</div>
<p className="text-xs leading-snug text-muted-foreground">
This demo frame overrides Bottom Nav’s real fixed-position and
md:hidden classes so it stays pinned inside the box at every viewport
width — in real usage leave both alone; the bar is fixed to the
actual viewport and hidden at the md breakpoint (768px) and up.
“Alerts” shows the optional badge slot holding a real Badge component
for a count; “Profile” shows the same slot holding BottomNavDot, the
plain no-count indicator (paired here with visually-hidden “New
activity” text). Both are caller-supplied nodes, not a built-in
count/notification system.
</p>
</div>
);
}Variants
| Variant | How |
|---|---|
| With count badge | BottomNavItem badge={<Badge variant="destructive">3</Badge>} — a real Badge, for a destination with an actual number to show ("3 unread"). |
| With dot indicator | BottomNavItem badge={<BottomNavDot />} — a plain filled circle, for "there's new activity here" with no count to display. |
| Without an indicator | Omit badge — the default, for destinations with nothing to flag. |
| Current item | BottomNavItem current (or bottomNavItemVariants({ current: true }) on a router Link) — see States and Accessibility. |
Both indicator variants share the same anchor treatment: pinned to the icon's own top-right corner
(right-0 top-0), then pushed fully outside it horizontally by its own width (translate-x-full)
but only half its own height vertically (-translate-y-1/2 — a CSS % transform resolves against
the element's own box, not its parent's). The indicator's left edge lands exactly at the icon's
right edge — it never overlaps the icon's box horizontally, however wide its content is, a
size-independent guarantee unlike a fixed pixel offset — while its center sits on the icon's own top
edge, so it reads as attached to the icon rather than floating above the bar. A
--background-colored ring keeps it reading as a distinct floating indicator rather than a shape
glued to the corner.
Not in V1: a mega-menu or long-press action sheet under an item, a centered "raised" action
button, badge count computation or a notification system of any kind (the caller supplies a
finished node), and an enforced 3–5 item count — the component renders however many
BottomNavItems it's given; 3–5 is usage guidance, not a validated prop.
States
Per BottomNavItem, using the same tokens Navbar already established for a
top-level nav link:
| State | Look |
|---|---|
| Resting | --muted-foreground icon + label, transparent background. |
| Hover | --foreground icon + label (pointer/hybrid devices only — most Bottom Nav consumers are touch). |
| Focus-visible | --scale-0-5 ring in --ring, :focus-visible only. |
| Current | --foreground icon + label, plus font-medium on the label and aria-current="page". No filled selected pill or background change — same reasoning as Navbar: the text-weight shift is the reinforcing visual signal, aria-current is the real one, so current does not rely on color alone. |
Usage Guidance
Tokens
Bottom Nav composes the same --background / --border / --muted-foreground / --foreground / --ring pairing Navbar uses for its chrome and link states, and reuses Badge for the optional notification indicator; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--background | BottomNav's translucent fill (/80 + backdrop-blur); also the indicator's separation ring (ring-background) |
--border | BottomNav's top edge |
--muted-foreground | Resting item icon + label |
--foreground | Hover / current item icon + label |
--ring | Focus-visible ring on an item |
--scale-2 | Base bottom padding, stacked with the device safe-area inset; also BottomNavDot's diameter |
--scale-5 | Icon size (icons.md's "default UI" recipe) |
--scale-16 | Minimum per-item touch-target height (64px) |
--destructive | BottomNavDot's default fill (an alert-style "new activity" marker, the same intent a count Badge typically uses here) |
Do / Don’t
Do
- Keep it to 3–5 primary, app-level destinations — the anatomy (icon + one short word) doesn't read well past five items and starts to compete with itself below three.
- Give every item a real, short
label— it's the item's accessible name, never decoration. Don't ship an icon-only item. - Mark exactly one item
current, matching the section the user is in — computed by the caller (usePathname, a router match), same as Navbar / Sidebar Nav. - Pad the page's own scrollable content by at least the bar's height (
--scale-16) plus a safe margin, so real content never ends up hidden underneath the fixed bar. - Reserve the badge slot for something the destination itself is about (an unread count on "Inbox") — not an unrelated global indicator.
- Reach for a count
Badgewhen there's an actual number to show, andBottomNavDotwhen there isn't — don't fake a count with a dot, or pad a dot's job out to a "1" badge.
Do — hide it where it doesn't belong
- Leave
BottomNavmounted once, at the app/site shell level, alongsideNavbarorSidebar Navfor wider viewports — it hides itself atmdand up (Responsive contract), so a page doesn't need to conditionally render it.
Don’t
- Use it for a desktop or tablet-primary product — reach for Navbar or Sidebar Nav instead; Bottom Nav is deliberately mobile-only and does not adapt its own layout for wider screens.
- Give it more than five items, or fewer than three — see Do, above.
- Mark more than one item
current. - Build a second fixed-bottom element (e.g. a floating action button) that overlaps it without checking the combined safe-area / height budget.
- Compute or store notification counts inside the component —
badgetakes a finished node; own that state at the call site (guardrails: no invented notification system). - Render a bare
BottomNavDotwith no accompanying accessible text when it means something a sighted user would notice — a dot alone has no accessible name (see Accessibility).
Accessibility
- Landmark —
BottomNavrenders a<nav>witharia-labeldefaulting to "Primary" (applied after{...props}, so it can't be dropped by accident); override it when a page already has another nav landmark named "Primary". - Current location — the current item is
aria-current="page"(WAI-ARIA APG). Exactly one per nav. Thefont-mediumlabel-weight change is reinforcement;aria-currentis the real signal — current state never relies on color alone. - Links — native
<a>elements (or the caller's routerLink):Tabmoves between them,Enter/Spaceactivates, the focus ring is keyboard-only. No roving tabindex — every item is a normal tab stop, the same model as Navbar / Sidebar Nav. - Icons — rendered inside an
aria-hiddenwrapper; the visiblelabeltext is always the item's accessible name. - Indicator — rendered as a plain sibling of the (hidden) icon, so a count
Badge's content stays in the accessibility tree by default. Bothbadgevariants are caller-supplied, with no built-in semantics: give a countBadgeits own accessible text when a lone number is ambiguous out of context (e.g. a visually-hidden "3 unread" alongside its visible "3"), and always pair a bareBottomNavDotwith visually-hidden text (e.g. "New activity") — unlike a number, a plain dot carries no information at all without one. - Touch target — every item has a
--scale-16(64px) minimum height and grows to an equal share of the bar's width (flex-1), comfortably clearing common 44px minimum touch-target guidance. - Contrast — resting
--muted-foregroundand current/hover--foregroundboth meet WCAG AA against--background, the same pairing already validated for Navbar.
Responsive Behavior
BottomNav bakes in its own visibility — this is not a caller composition the way Sidebar Nav's
rail-vs-drawer split is.
- Below
md(768px):BottomNavrenders fixed to the bottom of the viewport. mdand up:BottomNavrenders nothing (md:hidden). A product that needs primary navigation at wider viewports mounts Navbar or Sidebar Nav alongside it — Bottom Nav does not grow, reflow, or turn into either at wider widths; it simply steps aside.- This is a real viewport-width media query (
md:hidden), the same approach Navbar and Sidebar Nav's own responsive example use — not a container query. Resize the actual browser window below 768px (or view on a phone) to see it; the live example on this page runs inside a fixed-size preview frame that deliberately overrides both thefixedpositioning and themd:hiddenvisibility so it stays visible for review without resizing the window — a demo-only override spelled out under the example, not real Bottom Nav behavior. - Bottom padding stacks a
--scale-2base gap withenv(safe-area-inset-bottom), so the bar clears a device's home-indicator/gesture area without losing its own minimum breathing room on a device that reports no inset.
Related Components / Patterns
- Navbar — top-of-page navigation.
- Sidebar Nav — the wide-viewport form of app-shell navigation.
- Navigation — how the navigation primitives combine on one screen.