Skip to content
Monogem

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 a Badge into 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

bottom-nav-showcase.tsx
"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

VariantHow
With count badgeBottomNavItem badge={<Badge variant="destructive">3</Badge>} — a real Badge, for a destination with an actual number to show ("3 unread").
With dot indicatorBottomNavItem badge={<BottomNavDot />} — a plain filled circle, for "there's new activity here" with no count to display.
Without an indicatorOmit badge — the default, for destinations with nothing to flag.
Current itemBottomNavItem 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:

StateLook
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.

TokenWhere used
--backgroundBottomNav's translucent fill (/80 + backdrop-blur); also the indicator's separation ring (ring-background)
--borderBottomNav's top edge
--muted-foregroundResting item icon + label
--foregroundHover / current item icon + label
--ringFocus-visible ring on an item
--scale-2Base bottom padding, stacked with the device safe-area inset; also BottomNavDot's diameter
--scale-5Icon size (icons.md's "default UI" recipe)
--scale-16Minimum per-item touch-target height (64px)
--destructiveBottomNavDot'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 Badge when there's an actual number to show, and BottomNavDot when 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 BottomNav mounted once, at the app/site shell level, alongside Navbar or Sidebar Nav for wider viewports — it hides itself at md and 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 — badge takes a finished node; own that state at the call site (guardrails: no invented notification system).
  • Render a bare BottomNavDot with no accompanying accessible text when it means something a sighted user would notice — a dot alone has no accessible name (see Accessibility).

Accessibility

  • Landmark — BottomNav renders a <nav> with aria-label defaulting 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. The font-medium label-weight change is reinforcement; aria-current is the real signal — current state never relies on color alone.
  • Links — native <a> elements (or the caller's router Link): Tab moves between them, Enter/Space activates, 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-hidden wrapper; the visible label text 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. Both badge variants are caller-supplied, with no built-in semantics: give a count Badge its 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 bare BottomNavDot with 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-foreground and current/hover --foreground both 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): BottomNav renders fixed to the bottom of the viewport.
  • md and up: BottomNav renders 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 the fixed positioning and the md:hidden visibility 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-2 base gap with env(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.