Mobile & Consumer

Bottom Tab Bar

A mobile bottom navigation where the active icon pops and badge counts tick, with hover gated to fine pointers.

Install

npx shadcn@latest add @paragon/bottom-tab-bar

bottom-tab-bar.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";

export interface BottomTab {
  value: string;
  label: string;
  icon: LucideIcon;
  /** Optional badge count on the icon. */
  badge?: number;
}

export interface BottomTabBarProps
  extends Omit<React.ComponentProps<"nav">, "onChange"> {
  tabs: BottomTab[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Show labels under icons. */
  showLabels?: boolean;
  static?: boolean;
}

/**
 * A mobile bottom navigation bar. The active tab's icon pops from 0.9→1 and
 * its label/tint switch to the foreground; badges pop when their count rises
 * and read in tabular figures. Hover tint is gated to fine pointers so touch
 * taps don't leave a stuck hover. Reduced motion drops the pop, keeps the
 * color state.
 */
export function BottomTabBar({
  tabs,
  value: valueProp,
  defaultValue,
  onValueChange,
  showLabels = true,
  static: isStatic = false,
  className,
  ...props
}: BottomTabBarProps) {
  const reduced = useReducedMotion() ?? false;
  const [uncontrolled, setUncontrolled] = React.useState(
    defaultValue ?? tabs[0]?.value,
  );
  const value = valueProp ?? uncontrolled;

  const select = (next: string) => {
    if (valueProp === undefined) setUncontrolled(next);
    onValueChange?.(next);
  };

  return (
    <nav
      role="tablist"
      aria-label="Primary"
      className={cn(
        "flex w-full items-stretch gap-1 rounded-2xl bg-card p-1.5 shadow-border",
        className,
      )}
      {...props}
    >
      {tabs.map((tab) => {
        const active = tab.value === value;
        const Icon = tab.icon;
        return (
          <button
            key={tab.value}
            type="button"
            role="tab"
            aria-selected={active}
            aria-label={tab.label}
            onClick={() => select(tab.value)}
            className={cn(
              "relative flex flex-1 flex-col items-center justify-center gap-1 rounded-xl px-2 py-2 text-[11px] font-medium outline-none transition-[color] duration-150 ease-out select-none focus-visible:ring-2 focus-visible:ring-ring",
              active ? "text-foreground" : "text-muted-foreground",
              "[@media(hover:hover)and(pointer:fine)]:hover:text-foreground",
            )}
          >
            <span className="relative">
              <motion.span
                aria-hidden
                initial={false}
                animate={
                  isStatic || reduced
                    ? undefined
                    : { scale: active ? 1 : 0.9 }
                }
                transition={{ type: "spring", duration: 0.3, bounce: 0.25 }}
                className="flex"
              >
                <Icon
                  className="size-5"
                  strokeWidth={active ? 2.25 : 2}
                />
              </motion.span>
              {tab.badge !== undefined && tab.badge > 0 && (
                <TabBadge count={tab.badge} static={isStatic} />
              )}
            </span>
            {showLabels && <span>{tab.label}</span>}
          </button>
        );
      })}
    </nav>
  );
}

function TabBadge({
  count,
  static: isStatic,
}: {
  count: number;
  static?: boolean;
}) {
  const reduced = useReducedMotion() ?? false;
  const prev = React.useRef(count);
  const rising = count > prev.current;
  React.useEffect(() => {
    prev.current = count;
  }, [count]);

  return (
    <AnimatePresence>
      <motion.span
        key="badge"
        aria-hidden
        initial={{ scale: 0.6, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.6, opacity: 0 }}
        transition={
          isStatic || reduced
            ? { duration: 0 }
            : { type: "spring", duration: 0.3, bounce: rising ? 0.3 : 0 }
        }
        className="absolute -right-2 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold leading-none text-destructive-foreground tabular-nums"
      >
        {count > 99 ? "99+" : count}
      </motion.span>
    </AnimatePresence>
  );
}