Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
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