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.
| Part | Description |
|---|---|
| Track | The full-width container. Fixed to the bottom of the viewport, above safe-area insets. |
| Tab button | flex-1 touch target. Minimum 44px height on mobile. Contains icon, label, and optional badge. |
| Icon | 18–22px. Use filled variant for active, outline for inactive — mirrors iOS HIG. |
| Label | 10px, font-weight 500. Always visible — never icon-only in a bottom tab bar. |
| Badge | Small pill above the icon. Number > 99 shows 99+. Dot badge (no number) for generic unread. |
| Active state | Accent 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 destinations | You have 6+ destinations — use a drawer instead |
| All destinations are equally important | One destination is used 80%+ of the time |
| Users switch between sections frequently | Destinations are deeply hierarchical |
| The app is mobile-first or native-feeling | You 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
| Prop | Type | Default | Description |
|---|---|---|---|
items | BottomTabItem[] | — | The tab items to render. |
value | string | — | Controlled active tab value. |
onChange | (value: string) => void | — | Called when a tab is tapped. |
className | string | — | Extra classes for the wrapper element. |
BottomTabItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Unique identifier for this tab. |
label | string | — | Text label shown below the icon. |
icon | ReactNode | — | Icon element — 18–24px recommended. |
badge | number | string | — | Badge count or label. Numbers > 99 render as 99+. |
Mobile
| Scenario | Guidance |
|---|---|
| Touch target height | Minimum 49px total height (Apple HIG). The 44px tap area minimum applies per item; additional padding around the icon + label is expected. |
| Safe area insets | Add padding-bottom: env(safe-area-inset-bottom, 0px) to clear the iPhone home indicator. Without it, content and the home indicator overlap. |
| Tab count | Cap 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 blur | Use backdrop-filter: blur on the track so page content can scroll behind it without hard color edges. Ensure sufficient contrast on both themes. |
| Keyboard avoidance | The 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.