Navigation

Bottom Nav

A mobile tab bar with badges, safe-area padding and an indicator that measures the active item and glides to it.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface BottomNavItem {
  key: string
  label: string
  icon: React.ReactNode
  /** Selected-state glyph (usually the filled twin of `icon`); falls back to `icon`. */
  activeIcon?: React.ReactNode
  /** Unread count. 0 / undefined renders nothing; anything above 99 renders "99+". */
  badge?: number
  /** When set the item renders a real <a> — routing stays with the consumer. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/bottom-nav.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "BottomNav" component (no animation
library — one measured transform plus a CSS transition).

Contract
- Export a forwardRef <nav> extending React.HTMLAttributes<HTMLElement>.
- items: { key, label, icon: ReactNode, activeIcon?: ReactNode, badge?: number,
  href?: string, onSelect?: () => void }[].
- Controlled selection: value: string plus onValueChange?: (key) => void.
  Items carry their own onSelect for per-item side effects; both fire on tap.
- showLabels?: "always" | "active" | "never" (default "always").
- indicator?: "bar" | "pill" | "none" (default "bar").
- label?: string (default "Primary") becomes the <nav> aria-label.

Behavior
- Item element depends on the data: an item with href renders a real <a> (the
  consumer's router or a plain link owns navigation); otherwise a
  type="button". Both call onValueChange(key) then item.onSelect().
- The selected item gets aria-current="page"; its icon swaps to activeIcon
  when provided, otherwise keeps icon.
- Indicator: keep a Map of item key -> element via callback refs. In an effect
  keyed on [value, items, indicator], read the active element's offsetLeft /
  offsetWidth and store them in state; the row is position:relative so it is
  both the indicator's containing block and its offsetParent, which makes
  offsetLeft directly usable as translateX. Bail out (and clear state) when
  indicator is "none" or the element is missing.
- Recompute on layout changes with a ResizeObserver on the row, disconnected
  on unmount; skip observing entirely when ResizeObserver is undefined.
  Compare before setState so an unchanged measurement never re-renders.
- First paint must not animate: render the indicator without a transition
  class, then enable transitions in a requestAnimationFrame (cancelled on
  unmount) so it snaps into place on mount and glides on every later change.
- Badge: 0 / undefined renders nothing, values above 99 render "99+". Because
  the label can be hidden, every item always carries an aria-label —
  "<label>, <badge> new" when a badge is present.
- Press feedback: the icon wrapper scales down slightly via group-active.

Rendering & styling
- Semantic tokens only: bg-background/95 + backdrop-blur + border for the bar,
  bg-primary for the "bar" indicator and the badge (text-primary-foreground),
  bg-accent for the "pill" indicator, text-primary for the active item,
  text-muted-foreground -> hover:text-foreground for the rest.
- Root gets pb-[env(safe-area-inset-bottom)] so a fixed bar clears the home
  indicator on notched phones.
- Items are flex-1 min-w-0 with a fixed height, so switching showLabels
  ("active") never changes the bar's height; labels are truncate, never wrap,
  and pin their own line-height (leading-tight) — an inherited typographic
  line-height (e.g. prose) would otherwise inflate the label box, shove the
  icon flush against the top edge and push the badge out of the bar.
- The indicator is absolute + aria-hidden + pointer-events-none and precedes
  the items in the DOM; items are position:relative so they paint above it.
  It lives inside an inset-0 overflow-hidden layer that inherits the bar's
  radius, so at the first/last item the rail is clipped along the rounded
  corner instead of overhanging the outline; badges sit outside that layer
  and are never clipped.
- Reduced motion: the transition classes carry motion-reduce:transition-none —
  the indicator still moves, it just stops animating.
- focus-visible:ring-2 ring-ring ring-inset (an outset ring would clip against
  the bar's rounded edge). Merge consumer className on the <nav> via cn().

Customization levers
- Density: item height (h-14) and the icon size ([&_svg]:size-5) are the two
  knobs; label text size follows.
- Indicator shape: "bar" is a 2px top rail, "pill" is an inset rounded block —
  adding a third shape is one class branch, the measuring code is shared.
- Glide feel: duration-300 ease-out on transition-[transform,width]; drop to
  ~150ms for a snappier phone feel.
- Tokens: swap bg-primary for bg-foreground on monochrome themes, or the pill's
  bg-accent for bg-primary/10 for a tinted look.
- Fixed positioning: add fixed inset-x-0 bottom-0 z-50 (and rounded-none) via
  className to pin it to the viewport instead of sitting inline.
- Routing: pass href for real anchors, or leave it off and drive a router from
  onValueChange — the component never navigates by itself.

Concepts

  • Measured indicator — the rail has no idea how many items exist; it reads the active item's own box and moves by transform, so 3 items or 6, equal or uneven widths, all work without configuration.
  • Mount snap, then glide — transitions are switched on one frame after the first measurement, which is what stops the indicator from flying in from the left edge on page load.
  • Anchor-or-button items — an item with href becomes a real <a> (middle-click, open-in-new-tab and prefetching keep working); everything else is a <button>. Selection state is reported either way, so a router can stay the source of truth.
  • Badge overflow — counts are display data, not layout: anything above 99 collapses to 99+ so the badge never widens the item, and the count is folded into the item's aria-label because the visible label may be hidden.
  • Safe-area insetpb-[env(safe-area-inset-bottom)] pads the bar by the device's home-indicator height, the one piece of geometry that must come from the OS rather than the theme.
  • Label policy over label propshowLabels is a single policy for the whole bar ("always" / "active" / "never") and the item height is fixed, so switching policies never reflows the surrounding shell.

On This Page