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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/bottom-nav.jsonPrompt
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
hrefbecomes 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'saria-labelbecause the visible label may be hidden. - Safe-area inset —
pb-[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 prop —
showLabelsis a single policy for the whole bar ("always" / "active" / "never") and the item height is fixed, so switching policies never reflows the surrounding shell.