Mobile tab bar
A bottom bar for phones with up to five icon and label links and a count badge.
npx shadcn@latest add https://ui.goodtabs.co/r/mobile-tab-bar.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/mobile-tab-bar
Five items in a phone-width frame, the first one current.
With a badge
Counts on two items. The badge is mono on the primary fill.
Three items
Fewer items share the width evenly.
Usage
import { MobileTabBar } from "@/components/ui/mobile-tab-bar"
import { HomeIcon, SearchIcon, UserIcon } from "lucide-react"
<MobileTabBar
position="fixed"
className="md:hidden"
items={[
{ label: "Home", href: "/", icon: <HomeIcon />, active: true },
{ label: "Search", href: "/search", icon: <SearchIcon /> },
{ label: "You", href: "/me", icon: <UserIcon />, badge: 2 },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { label: string; href: string; icon: ReactNode; active?: boolean; badge?: number | string }[] | Up to five links. Extra items are dropped. | |
| position | "fixed" | "static" | "static" | fixed pins it to the bottom of the viewport; static keeps it in flow. |
Source
mobile-tab-bar/mobile-tab-bar.tsx
import * as React from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
export type MobileTabBarItem = {
label: string;
href: string;
/** A 20px icon, for example a lucide icon. */
icon: React.ReactNode;
/** Marks the current page. Sets aria-current and the foreground colour. */
active?: boolean;
/** A small count on the icon. Keep it to two characters. */
badge?: number | string;
};
type MobileTabBarProps = Omit<React.ComponentProps<"nav">, "children"> & {
/** Up to five items; more are dropped. */
items: MobileTabBarItem[];
/** fixed pins it to the bottom of the viewport; static keeps it in flow. */
position?: "fixed" | "static";
};
/**
* A bottom tab bar for phones: up to five links with an icon and a short label.
* 56px tall, hairline top, pads for the home indicator on notched phones.
*/
export function MobileTabBar({ items, position = "static", className, ...props }: MobileTabBarProps) {
return (
<nav
data-slot="mobile-tab-bar"
aria-label="Primary"
className={cn(
"w-full border-t bg-background pb-[env(safe-area-inset-bottom)]",
position === "fixed" && "fixed inset-x-0 bottom-0 z-20",
className,
)}
{...props}
>
<ul className="m-0 flex h-14 list-none items-stretch p-0">
{items.slice(0, 5).map((item) => (
<li key={`${item.href}-${item.label}`} className="flex min-w-0 flex-1">
<Link
href={item.href}
aria-current={item.active ? "page" : undefined}
className={cn(
"flex w-full min-w-0 flex-col items-center justify-center gap-1 rounded-sm px-1 text-xs transition-colors duration-100 ease-out outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset",
item.active ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<span className="relative inline-flex [&_svg]:size-5" aria-hidden="true">
{item.icon}
{item.badge !== undefined && (
<span className="absolute -top-1 -right-2 inline-flex size-4 items-center justify-center rounded-full bg-primary font-mono text-[10px] leading-none text-primary-foreground">
{item.badge}
</span>
)}
</span>
<span className="max-w-full truncate leading-none">{item.label}</span>
{item.badge !== undefined && <span className="sr-only">, {item.badge}</span>}
</Link>
</li>
))}
</ul>
</nav>
);
}
For AI agents
Hand your agent this page, the markdown at /components/mobile-tab-bar/prompt.md, or the Copy AI prompt button above.
When to use
- The three to five top-level places in an app, on phone widths
- When people switch between those places often and need one tap to get there
- With a count badge for an inbox or notifications
When not to use
- On desktop: hide it from md up and use a sidebar or navbar
- For more than five destinations: put the rest behind a More page
- For actions like compose or upload: use a button, not a tab
Agent notes
- Server component. Links use next/link, so pass real hrefs and set active from the pathname yourself
- position defaults to static so it stays inside demos; pass position="fixed" with className="md:hidden" in an app and add pb-14 to the page
- It pads the bottom with env(safe-area-inset-bottom); set viewport-fit=cover in the viewport meta for that to apply
- Icons inherit currentColor and are sized to 20px; mark them aria-hidden, the label is the accessible name