Skip to content

Bottom Tab Bar

Keep a phone layout’s main destinations within reach at the bottom of the screen.

import { BottomTabBar, BottomTabBarItem } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use it for the three to five top-level destinations of a phone layout: the places people switch between all day, within reach of the thumb. Each tab goes to a page, so the tabs are links. To switch panels of content inside one page, use Tabs. On wide screens the same destinations belong in an App Header or a Sidebar, so render the bar only below your phone breakpoint.

import { BottomTabBar, BottomTabBarItem } from '@youj/react'
import { HomeIcon, InboxIcon, SearchIcon, UserIcon } from '@youj/icons'

export function PhoneNav({ pathname }: { pathname: string }) {
  return (
    <BottomTabBar className="md:hidden">
      <BottomTabBarItem href="/" icon={<HomeIcon />} label="Home" active={pathname === '/'} />
      <BottomTabBarItem href="/search" icon={<SearchIcon />} label="Search" active={pathname === '/search'} />
      <BottomTabBarItem href="/inbox" icon={<InboxIcon />} label="Inbox" badge={3} active={pathname === '/inbox'} />
      <BottomTabBarItem href="/me" icon={<UserIcon />} label="Profile" active={pathname === '/me'} />
    </BottomTabBar>
  )
}

The bar is fixed to the bottom of the viewport and pads itself by the safe-area insets, so it clears the home indicator and the rounded corners on its own. For that to work, the page needs viewport-fit=cover in its viewport meta tag. Give the page's content bottom padding as tall as the bar, so nothing ends up hidden behind it. The previews pass className="absolute" to place it inside a phone frame instead.

Each BottomTabBarItem renders an <a>. Pass render for a router's link, for example render={<Link href="/inbox" />}. Mark the current destination with active: its icon sits on a pill, which glides from tab to tab on the smooth spring, and its icon and label turn to ink.

Reference

Propsicon, label, active… (5)

Props

BottomTabBarItem props
PropType
iconRequiredReactNode–
labelRequiredReactNode–
activeboolean

The current destination.

default false

false
badgenumber | boolean

A count (99+ past 99) or true for a dot.

–
badgeLabelstring

How the badge is read after the label. Default "N new", or "new" for a dot.

–
Other props pass through: extends useRender.ComponentProps<'a'>.

BottomTabBar is a nav and takes its props. Its aria-label defaults to "Primary".

MotionCurrent pill, label and icon color, icon · reduced motion

Motion

Motion tokens
MomentTokens
Current pillone Sliding Indicator glides to the current tab, hugging its icon
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Label and icon colorto ink on the current tab, and on hover
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Icona Youj icon plays its own motion on hover and keyboard focus
no token

per icon

per icon

Reduced motion

The pill jumps to the new tab, colors change at once, and icons stay still.

Reduced motion

The pill moves to the new tab at once instead of gliding, and the icons don't play. Colors change instantly. Navigation and focus work the same.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • The bar is a nav landmark named "Primary". Rename it with aria-label if the page has another primary navigation.
  • Tabs are links in a list, so Tab reaches each one and the browser's own link handling applies: open in a new tab, copy the address, and so on. The current tab carries aria-current="page".
  • The badge is hidden from assistive technology and read as text after the label, so "Inbox, 3 new" is one name.
  • The focus ring draws around the icon's pill, where the eye already is. Each tab is at least 56px tall.