Bottom Navigation
Three to five destinations in the thumb zone. A sixth item drops each target below the width where thumbs stop being accurate.
Also called Tab Bar, Navigation Bar, Mobile Nav — in this system all of them are Bottom Navigation.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Three, four, five
Five is the ceiling. At six, each target is under 64px on a standard phone and mis-taps become common.
With a floating action button
The FAB sits above the bar rather than inside it. Putting the create action in the bar makes it look like a destination, and it is not one.
The thumb zone
On a phone held one-handed, the bottom third of the screen is comfortable, the middle is a stretch, and the top corners require a grip change. Navigation belongs at the bottom for the same reason.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Four destinations, one active. The active item gets a pill behind its icon plus a colour change plus a heavier label — three signals, so it survives greyscale.
- Bar height56px + safe area
The safe-area inset is added, not included. On a gesture-navigation phone that is another ~34px that belongs to the system.
- Item widthmin 64px, flexes
Below 64px thumb accuracy drops measurably. That is what caps the bar at five destinations.
- Icon18px in a 56 × 28 pill
The pill appears only on the active item. It is wider than the icon so the highlight reads as a target rather than as a badge.
- Label11px, always visible
Small, but present. An icon-only bar turns recognition into recall on the surface with the least patience.
- Count badgeTop-right of the icon
Overlapping the icon rather than beside the label, so it does not change the item width when the count appears.
- Backgroundsurface at 85% + blur
Translucent, so content scrolling underneath is faintly visible and the bar reads as floating above the list.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface | — | Bar background at 85% alpha |
| --ds-border-subtle | — | Top edge |
| --ds-accent-subtle | — | Active pill |
| --ds-accent-text | — | Active icon and label |
| --ds-fg-muted | — | Inactive items |
Spacing
| Token | Value | Used for |
|---|---|---|
| height | Bar height | |
| item min-width | Thumb accuracy floor | |
| gap | Icon to label |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-overline | Labels |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Pill and colour transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Min width | Touch target | When to use |
|---|---|---|---|---|
| Three items | 56px | 120px each | 56px | Simple apps. Generous targets. |
| Four items | 56px | 90px each | 56px | The most common. Comfortable on any phone. |
| Five items | 56px | 72px each | 56px | The ceiling. Fine on a 360px screen, tight below it. |
| Safe area | + env(safe-area-inset-bottom) | — | — | Added below the bar. Typically 34px on gesture-navigation phones. |
| FAB clearance | 16px above the bar | — | — | When a FAB is present, it floats above rather than sitting inside. |
padding-block-end: env(safe-area-inset-bottom)Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The active item must be distinguishable from the inactive ones by more than colour. Ours changes the fill, the icon colour and the label weight together.
- The inactive label at 11px must still reach 4.5:1. Small text has no contrast exemption.
- The top edge of the bar must reach 3:1 against the content, or the bar merges into the list behind it.
Keyboard
| Tab | Reaches each destination. Bottom navigation is usually touch, but it must still be keyboard reachable. |
| Enter | Navigates. |
| ↑ from content | Nothing special — the bar is in DOM order after the main content, which is correct. |
Screen readers
- Announced as "Alerts, 3 unread alerts, link, current page". The count needs its own accessible name.
- Keep the bar after the main content in the DOM. Navigation before content means every screen starts with four links.
- Do not change the bar between screens. WCAG 3.2.3 is about consistency, and a bar that varies destroys the positional memory it exists to build.
Focus & touch
- The focus ring is inset, because the bar sits at the very bottom edge of the viewport and an outset ring would be clipped by the screen.
- 56px tall plus the safe-area inset, with each item at least 64px wide. The whole item — icon, pill and label — is one target; never make only the icon tappable.
| Attribute | Applied to | Notes |
|---|---|---|
| <nav aria-label="Primary"> | The bar | A named landmark, distinct from any other nav on the page. |
| aria-current="page" | The active item | The only reliable way for a screen reader to convey which destination is active. |
| aria-label | The count badge | "3 unread alerts", not "3". |
| DOM order | The bar | Render it after the main content so screen-reader users reach the content first. |
Example usage
1import { BottomNav } from '@/ui/Navigation'23// Render AFTER the main content in the DOM4<main className="flex-1 overflow-y-auto pb-[calc(56px+env(safe-area-inset-bottom))]">5 {children}6</main>78<BottomNav9 items={[10 { value: 'home', label: 'Home', icon: <Home size={18} /> },11 { value: 'search', label: 'Search', icon: <Search size={18} /> },12 { value: 'alerts', label: 'Alerts', icon: <Bell size={18} />, count: unread },13 { value: 'account', label: 'Account', icon: <User size={18} /> },14 ]}15 value={tab}16 onChange={handleChange}17/>1819// Preserve each destination's stack, and scroll to top on re-tap20function handleChange(next: string) {21 if (next === tab) {22 scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })23 return24 }25 stacks.current[tab] = window.scrollY // remember where we were26 setTab(next)27 requestAnimationFrame(() => window.scrollTo(0, stacks.current[next] ?? 0))28}2930// Only render it where it belongs31const isMobile = useMediaQuery('(max-width: 767px)')32{isMobile ? <BottomNav … /> : <Sidebar … />}CSS
.ds-bottom-nav {
display: flex;
align-items: stretch;
justify-content: space-around;
border-block-start: 1px solid var(--ds-border-subtle);
/* Translucent so the list scrolling beneath stays faintly visible */
background: color-mix(in oklab, var(--ds-surface) 85%, transparent);
backdrop-filter: blur(16px);
/* The inset is ADDED below the bar, not included in its height */
padding-block-end: env(safe-area-inset-bottom);
}
.ds-bottom-nav__item {
flex: 1;
min-inline-size: 64px; /* the thumb-accuracy floor */
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding-block: 8px;
color: var(--ds-fg-muted);
}
/* Three signals at once, so the state survives greyscale */
.ds-bottom-nav__item[aria-current='page'] { color: var(--ds-accent-text); }
.ds-bottom-nav__item[aria-current='page'] .ds-bottom-nav__pill {
background: var(--ds-accent-subtle);
}
.ds-bottom-nav__item[aria-current='page'] .ds-bottom-nav__label {
font-weight: 600;
}
.ds-bottom-nav__pill {
display: grid;
place-items: center;
inline-size: 56px;
block-size: 28px;
border-radius: 999px;
transition: background-color 180ms var(--ease-emphasized);
}
/* Inset, because an outset ring at the screen edge is clipped */
.ds-bottom-nav__item:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: -2px;
}
/* Desktop navigation belongs at the top or the side */
@media (min-width: 768px) { .ds-bottom-nav { display: none; } }Component API
BottomNav
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | { value, label, icon, count? }[] | — | Three to five. Never six. |
| value* | string | — | The active destination. |
| onChange* | (v: string) => void | — | Fires on tap, including a tap on the already-active item. |
Professional tips
- Put the most-used destination first, on the left. It is where the thumb rests and where users look first.
- A badge on the bar should mean "something needs you", not "something happened". A permanent unread count on Alerts becomes invisible within a week.
- When a destination needs sub-navigation, use tabs inside it. Never a second bottom bar.
- Match the platform. iOS users expect a tap on the active tab to scroll to top; Android users expect the back button to return to the first destination.
Performance
- Keep all destination stacks mounted but only the active one visible if memory allows — remounting on every switch loses scroll position and refetches data.
- backdrop-filter on the bar re-composites on every scroll frame. It is worth it for one bar; do not add a second blurred surface.
- Prefetch the adjacent destinations on first render. Switching should be instant, and there are only ever four of them.
Common mistakes
- Six or more destinations, dropping each target below the thumb-accuracy floor.
- Icon-only items, turning recognition into recall on the least forgiving surface.
- Ignoring the safe-area inset, putting targets under the home-indicator swipe.
- Rendering the bar before the main content in the DOM, so screen-reader users hear the navigation first on every screen.
- Hiding the bar on scroll, removing the anchor to save 56 pixels.
Real-world recommendations
- If you cannot fit the product into five destinations, the problem is the information architecture. A "More" tab is a symptom, not a solution.
- Track taps per destination. A tab under 5% is usually a settings page that has been promoted beyond its usefulness.
- Test one-handed on the largest phone you support. The top-left corner of a 6.7-inch screen is genuinely unreachable, and that is what bottom navigation exists to avoid.
- Bottom navigation and a FAB together is the standard Material arrangement, but only when the create action is genuinely global. Otherwise the FAB belongs on the destination that owns it.