Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Home
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6

Three, four, five

Five is the ceiling. At six, each target is under 64px on a standard phone and mis-taps become common.

3 items · ~120px each
4 items · ~90px each
5 items · ~72px each

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.

Home
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6

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.

Hard to reach
A stretch
Comfortable

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.

Alerts
Active
Alerts
Inactive
3Alerts
With count
Alerts
Pressed
Alerts
Focus
Three items
Five items
+34px inset
Safe area

Anatomy

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.

  1. 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.

  2. Item widthmin 64px, flexes

    Below 64px thumb accuracy drops measurably. That is what caps the bar at five destinations.

  3. 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.

  4. Label11px, always visible

    Small, but present. An icon-only bar turns recognition into recall on the surface with the least patience.

  5. 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.

  6. Backgroundsurface at 85% + blur

    Translucent, so content scrolling underneath is faintly visible and the bar reads as floating above the list.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
heightBar height
item min-widthThumb accuracy floor
gapIcon to label

Typography

TokenValueUsed for
--text-overlineLabels

Motion

TokenValueUsed for
durationPill and colour transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightMin widthTouch targetWhen to use
Three items56px120px each56pxSimple apps. Generous targets.
Four items56px90px each56pxThe most common. Comfortable on any phone.
Five items56px72px each56pxThe 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 clearance16px above the bar——When a FAB is present, it floats above rather than sitting inside.

Do

Keep the labelsEleven pixels is small, but it is always there. An icon-only bar makes the user remember what a glyph means on the device where they have the least attention.
padding-block-end: env(safe-area-inset-bottom)
Respect the safe-area insetOn a gesture-navigation phone the bottom band belongs to the system. A bar that ignores it puts its targets under a swipe that dismisses the app.
Home › Project › Deploy → switch to Alerts → back to Home › Deploy
Preserve each destination’s stackReturning to a tab should return the user where they were, not to its root. This is the behaviour every native app has, and its absence is felt immediately.
active item tapped → scrollTo({ top: 0, behavior: 'smooth' })
Tap the active item to scroll to topA convention borrowed from iOS that costs nothing and saves a long scroll. Users try it on every app; it should work on yours.

Don't

Do not exceed five destinationsAt six, each target is under 64px on a standard phone. Mis-taps rise, and the labels start truncating to two characters.
Do not put an action in the barThe bar is destinations. A create button among them looks like a place to go, and tapping it produces a surface the back button cannot dismiss the way users expect.
scroll down → bar gone → user scrolls up to find it
Do not hide the bar on scrollIt saves 56px and costs the user their anchor. It also reappears unpredictably on a small upward scroll, which is worse than never hiding at all.
Do not use it on desktopA bar pinned to the bottom of a 1440px window is a long way from the content and a long way from the cursor. Desktop navigation belongs at the top or the side.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.4.8LocationAAA2.5.8Target Size (Minimum)AA3.2.3Consistent NavigationAA

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

TabReaches each destination. Bottom navigation is usually touch, but it must still be keyboard reachable.
EnterNavigates.
↑ from contentNothing 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.
AttributeApplied toNotes
<nav aria-label="Primary">The barA named landmark, distinct from any other nav on the page.
aria-current="page"The active itemThe only reliable way for a screen reader to convey which destination is active.
aria-labelThe count badge"3 unread alerts", not "3".
DOM orderThe barRender it after the main content so screen-reader users reach the content first.

Code

Example usage

tsx
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

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

PropTypeDefaultDescription
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.

Notes

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.