Bottom Tab Bar

A fixed row of tab buttons anchored to the bottom of the screen. The primary mobile navigation pattern — 2 to 5 destinations, each with an icon and label. Supports badge counts for unread indicators.

Preview

Active: home

Anatomy

Each tab is a full-height button with an icon, a short text label, and an optional badge. The active tab uses the accent color; all others use text-tertiary.

PartDescription
TrackThe full-width container. Fixed to the bottom of the viewport, above safe-area insets.
Tab buttonflex-1 touch target. Minimum 44px height on mobile. Contains icon, label, and optional badge.
Icon18–22px. Use filled variant for active, outline for inactive — mirrors iOS HIG.
Label10px, font-weight 500. Always visible — never icon-only in a bottom tab bar.
BadgeSmall pill above the icon. Number > 99 shows 99+. Dot badge (no number) for generic unread.
Active stateAccent color on icon + label. Optional scale-110 micro-animation on the icon.

When to use

Use a bottom tab bar when…Use a different pattern when…
You have 2–5 top-level destinationsYou have 6+ destinations — use a drawer instead
All destinations are equally importantOne destination is used 80%+ of the time
Users switch between sections frequentlyDestinations are deeply hierarchical
The app is mobile-first or native-feelingYou are building a desktop or tablet primary UI

Implementation

Place the tab bar outside your scrollable content area at the bottom of a flex flex-col h-dvh container. Use padding-bottom: env(safe-area-inset-bottom) to clear the iPhone home indicator.

BottomTabBar.tsx
tsx
import { useState } from "react";
import { BottomTabBar } from "@/components/ui/BottomTabBar";
import { Home, Search, Bell, User } from "lucide-react";

const TABS = [
  { value: "home",    label: "Home",    icon: <Home className="w-[18px] h-[18px]" /> },
  { value: "search",  label: "Search",  icon: <Search className="w-[18px] h-[18px]" /> },
  { value: "inbox",   label: "Inbox",   icon: <Bell className="w-[18px] h-[18px]" />, badge: 3 },
  { value: "profile", label: "Profile", icon: <User className="w-[18px] h-[18px]" /> },
];

export function App() {
  const [tab, setTab] = useState("home");

  return (
    <div className="flex flex-col h-dvh">
      <main className="flex-1 overflow-y-auto">
        {/* page content */}
      </main>
      <BottomTabBar
        items={TABS}
        value={tab}
        onChange={setTab}
      />
    </div>
  );
}

BottomTabBar props

PropTypeDefaultDescription
itemsBottomTabItem[]The tab items to render.
valuestringControlled active tab value.
onChange(value: string) => voidCalled when a tab is tapped.
classNamestringExtra classes for the wrapper element.

BottomTabItem

PropTypeDefaultDescription
valuestringUnique identifier for this tab.
labelstringText label shown below the icon.
iconReactNodeIcon element — 18–24px recommended.
badgenumber | stringBadge count or label. Numbers > 99 render as 99+.

Mobile

ScenarioGuidance
Touch target heightMinimum 49px total height (Apple HIG). The 44px tap area minimum applies per item; additional padding around the icon + label is expected.
Safe area insetsAdd padding-bottom: env(safe-area-inset-bottom, 0px) to clear the iPhone home indicator. Without it, content and the home indicator overlap.
Tab countCap at 5. Below 2 tabs is a toggle, not a tab bar. With 5 tabs on a 320px screen, each item is ~64px wide — acceptable but tight.
Backdrop blurUse backdrop-filter: blur on the track so page content can scroll behind it without hard color edges. Ensure sufficient contrast on both themes.
Keyboard avoidanceThe tab bar should not rise with the software keyboard. Use position: fixed (not sticky) and ensure the layout root is not inside a flex container with height: 100%.

Accessibility

  • The container has role="tablist" with aria-label="Navigation". Each tab has role="tab" and aria-selected.
  • Never use icon-only tabs without a visible text label in a bottom tab bar. Labels are required for discoverability.
  • Badge values should be announced by screen readers. Add aria-label to the badge span, e.g. aria-label="3 unread".
  • Keyboard: Tab moves focus between tabs; Enter/Space activates the focused tab.
  • On iOS VoiceOver, SwiftUI TabView is fully managed — don't override the built-in tab bar with custom SwiftUI views unless you replicate all accessibility properties.