A mobile bottom navigation where the active icon pops and badge counts tick, with hover gated to fine pointers.
npx shadcn@latest add @paragon/bottom-tab-bar"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>
);
}