Three to five phone destinations docked at the bottom, with icons and counts, hidden on wide screens.
Items and counts
A bottom tab bar links the pages an app opens most on a phone. It docks flush with the bottom edge on the float fill, with a hairline above and the safe area padded inside.
- Items: each
BottomTabBarItemis an icon over a one-line label. The page shown is an ink pill. - Counts: a small ink chip at the icon's corner, with its words in the name. The Transactions tab below reads "Transactions, 12 to review".
The examples on this page sit in a phone frame, so the bar docks at the frame's bottom edge, not the window's.
Example
Example
Spending is on track this month.
Twelve transactions are waiting for a category.
import { BottomTabBar, BottomTabBarItem } from '@snapshot-ui/react';
import { ArrowLeftRight, Ellipsis, House, Target } from 'lucide-react';
import type { ReactElement } from 'react';
export default function BottomTabBarBasic(): ReactElement {
return (
<div className="stage-phone">
<div style={{ padding: 'var(--spacing-inset)', overflowWrap: 'anywhere' }}>
<p style={{ margin: '0 0 16px' }}>Spending is on track this month.</p>
<p style={{ margin: 0 }}>Twelve transactions are waiting for a category.</p>
</div>
<BottomTabBar aria-label="Main">
<BottomTabBarItem href="#home" icon={<House />} isCurrent>
Home
</BottomTabBarItem>
<BottomTabBarItem href="#transactions" icon={<ArrowLeftRight />} count={12} countLabel="to review">
Transactions
</BottomTabBarItem>
<BottomTabBarItem href="#goals" icon={<Target />}>
Goals
</BottomTabBarItem>
<BottomTabBarItem icon={<Ellipsis />}>More</BottomTabBarItem>
</BottomTabBar>
</div>
);
}The More button
An item without href is a button. Wrap it in a SheetTrigger with a Sheet of the other pages to open the sheet, and it reports whether the sheet is open. Or pass onPress for your own action. isCurrent on a button draws the pill but sets no aria-current, since a button is not the page shown and the sheet carries its own current link.
Example
Example
More opens a sheet of the pages that have no tab.
import { BottomTabBar, BottomTabBarItem, Link, Sheet, SheetTrigger } from '@snapshot-ui/react';
import { ArrowLeftRight, Ellipsis, House, Target } from 'lucide-react';
import type { ReactElement } from 'react';
const PAGES = ['Accounts', 'Documents', 'Insights', 'Ask', 'Settings'];
export default function BottomTabBarMore(): ReactElement {
return (
<div className="stage-phone">
<div style={{ padding: 'var(--spacing-inset)', overflowWrap: 'anywhere' }}>
<p style={{ margin: 0 }}>More opens a sheet of the pages that have no tab.</p>
</div>
<BottomTabBar aria-label="Main, with More">
<BottomTabBarItem href="#home" icon={<House />} isCurrent>
Home
</BottomTabBarItem>
<BottomTabBarItem href="#transactions" icon={<ArrowLeftRight />}>
Transactions
</BottomTabBarItem>
<BottomTabBarItem href="#goals" icon={<Target />}>
Goals
</BottomTabBarItem>
<SheetTrigger>
<BottomTabBarItem icon={<Ellipsis />}>More</BottomTabBarItem>
<Sheet title="More" isTitleHidden>
<nav aria-label="More pages">
<ul style={{ display: 'grid', gap: 12, margin: 0, padding: 0, listStyle: 'none' }}>
{PAGES.map((page) => (
<li key={page}>
<Link href={`#${page.toLowerCase()}`}>{page}</Link>
</li>
))}
</ul>
</nav>
</Sheet>
</SheetTrigger>
</BottomTabBar>
</div>
);
}Capacity
Use four short labels, or five at most, each under about 60px at 375px. Each label gets (width - 8) / n - 8 pixels:
- Four tabs: 83.8px at 375px, 70px at 320px.
- Five tabs: 65px at 375px, 54px at 320px.
"Transactions" is 77.3px, so it fits four tabs from 350px and ellipsizes at 320px. On a phone, the frame around these examples is narrower than the screen, so it may ellipsize here but fit in your app. The label is one line, with an ellipsis as a last resort, and the accessible name always holds the whole label.
Label widths in Sofia Sans at 14px and weight 500: Transactions 77.3px, Documents 70.4, Accounts 57.5, Settings 50.9, Insights 48.4, Home 35.9, Goals 33.5 and More 30.6. So Home, Transactions, Goals and More fit at 375px.
The safe area
The bar pads the safe area under and beside its items, through env(safe-area-inset-bottom) and its left and right twins. On iOS these are 0 unless the page covers the whole screen. In Next.js:
// app/layout.tsx
import type { Viewport } from 'next';
export const viewport: Viewport = { viewportFit: 'cover' };Usage
CSS alone hides the bar from 1024px up, so it can stay in the page at every width.
- App shell: pass it as
bottomBar. Below 1024px, the page keeps the bar's 64px and the safe area clear under its end. - Routing: links navigate through React Aria's
RouterProvider, which Routing sets up once.renderhands an item's anchor to your own link component. - Item count: the bar warns once in the console with fewer than three items or more than five, and still draws them.
Props
className and ref go on the nav and on each item's link or button, with className merged after the library's class. An item with href takes a link's props, and one without takes a button's.
aria-label string | A name for assistive technology. Give it, or |
|---|---|
aria-labelledby string | The id of an element that holds the name, such as a label shown elsewhere. |
children ReactNode | Three to five BottomTabBarItem elements. The bar warns once, in the console, outside that range. |
className string |
children ReactNode | The label, one line at the small step. It is the accessible name. |
|---|---|
className string | ((values: LinkRenderProps & { defaultClassName: string | undefined; }) => string) | ((values: ButtonRenderProps & { defaultClassName: string | undefined; }) => string) | |
count string | number | |
countLabel string | |
icon ReactNode | The icon, 20px, hidden from assistive technology. |
isCurrent boolean | Draws the ink pill. On a link it also sets |
href string | |
|---|---|
onFocus ((e: React.FocusEvent<Element, Element>) => void) | Handler that is called when the element receives focus. |
onHoverStart ((e: HoverEvent) => void) | Handler that is called when a hover interaction starts. |
render ((props: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> | React.DetailedHTMLProps<LinkWithRequiredHref, HTMLAnchorElement>, renderProps: LinkRenderProps) => ReactElement) | Overrides the default DOM element with a custom render function.
This allows rendering existing components with built-in styles and behaviors
such as router links, animation libraries, and pre-styled components.
Note: You can check if |
routerOptions undefined | Options for the configured client side router. |
isDisabled boolean | Whether the button is disabled. |
|---|---|
onPress ((e: PressEvent) => void) | Handler that is called when the press is released over the target. |
Accessibility
The bar is a <nav> named by its aria-label, holding plain links and buttons in tab order. It has no tab roles: these are pages, and "tab bar" is only its name in prose.
- The page shown carries
aria-current="page". - Icons are hidden from assistive technology. A count's number and words are in the name.
- A button in a
SheetTriggerreports whether its sheet is open. - From 1024px up the bar is
display: none, so it leaves the accessibility tree, and a rail's nav and the bar's never both appear.
| TabShift+Tab | Walks the items. |
|---|---|
| Enter | Follows a link, or presses the More button. |
| Space | Presses a button only. |
Design rules
What keeps the bar in the system.
Keep to four short labels.
Show a count only for what needs attention.
Put the rest of the pages behind a More button.
Never give the bar six tabs.
Never write a label that needs two lines.
Never show a tab bar beside a rail on a wide screen.