Skip to content

Mobile-first tab bar

A bottom tab bar with safe-area padding, promoted to a horizontal top bar at md.

Navbarsstartermobiletabsbottom-barappsafe-area

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

components/blocks/navigation/mobile-first.tsx

This exact file renders the preview above.

'use client'

import { useState } from 'react'
import Link from 'next/link'
import { Compass, Heart, Home, Search, User } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Mobile-first bottom navigation
 *
 * A bottom tab bar for app-like products, promoted to a horizontal top bar at
 * `md`. Targets are 56px tall and the active item is marked with a filled icon
 * and a label — never colour alone.
 *
 * `env(safe-area-inset-bottom)` padding keeps the bar clear of the home
 * indicator on modern phones.
 */
const tabs = [
  { label: 'Home', href: '/starters/product/preview', icon: Home },
  { label: 'Explore', href: '/starters/product/preview/features', icon: Compass },
  { label: 'Search', href: '/search', icon: Search },
  { label: 'Saved', href: '/starters/product/preview/resources', icon: Heart },
  { label: 'Account', href: '/starters/product/preview/contact', icon: User },
]

export default function MobileFirstNavigation() {
  const [active, setActive] = useState('/starters/product/preview')

  return (
    <div className="relative flex min-h-96 w-full flex-col bg-canvas">
      <div className="flex-1 p-6">
        <h2 className="text-lg font-semibold text-ink-strong">Mobile-first navigation</h2>
        <p className="mt-2 max-w-prose text-sm text-ink-muted">
          A bottom tab bar below <code className="font-mono text-xs">md</code>, promoted to a top
          bar above it. Selecting a tab updates{' '}
          <code className="font-mono text-xs">aria-current</code>.
        </p>
      </div>

      <nav
        aria-label="Mobile-first example"
        className="sticky bottom-0 border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] md:order-first md:border-t-0 md:border-b"
      >
        <ul className="mx-auto flex max-w-2xl items-stretch justify-between md:justify-start md:gap-1 md:px-4">
          {tabs.map((tab) => {
            const Icon = tab.icon
            const isActive = tab.href === active
            return (
              <li key={tab.href} className="flex-1 md:flex-none">
                <Link
                  href={tab.href}
                  onClick={(event) => {
                    // Demo only: keep the preview in place while showing the state change.
                    event.preventDefault()
                    setActive(tab.href)
                  }}
                  aria-current={isActive ? 'page' : undefined}
                  className={cn(
                    'flex min-h-14 flex-col items-center justify-center gap-1 px-2 text-2xs font-medium transition-colors md:min-h-11 md:flex-row md:gap-2 md:rounded-md md:px-3 md:text-sm',
                    isActive ? 'text-accent' : 'text-ink-subtle hover:text-ink',
                  )}
                >
                  <Icon
                    className={cn('size-5 md:size-4', isActive && 'fill-current stroke-[1.5]')}
                    aria-hidden="true"
                  />
                  {tab.label}
                </Link>
              </li>
            )
          })}
        </ul>
      </nav>
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For app-like products where the primary destinations should be reachable with a thumb. One markup, reordered with `order` at `md`, rather than two components kept in sync by hand.

  • `env(safe-area-inset-bottom)` padding keeps the bar clear of the home indicator.
  • The active tab is filled *and* coloured *and* marked with `aria-current` — three signals.
  • Five tabs is the ceiling; beyond that the labels stop fitting on a 390px screen.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Bottom bar on mobile
  • Top bar at md
  • Filled active icon

Accessibility

Targets
Every tab is at least 56px tall on mobile.
Current page
`aria-current="page"` moves with the selection.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.