Navigation

TabBar

The bar of tabs at the bottom of a phone screen: two to five of the app's top-level pages, each an icon over a short label, with the current page marked aria-current="page" and an accent wash. An accessory, such as Search, sits apart in a circle of its own, as in iOS. The bar is navigation only and the same on every page: put page actions (create, share, edit) on the page itself, never in the bar, so a tab never changes meaning.

On a phone the bar sits at the bottom of the screen. It hides from 640px by default; this demo keeps it at every width.

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

See it live, no sign-in needed: Home · A recipe · Recipes

In its source: app/components/navigation/mobile-nav.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/tab-bar#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/tab-bar

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/tab-bar#v1.2.13 ourostack/ouro-ui/tab-bar-tests#v1.2.13
import { TabBar } from "@/components/ouro/tab-bar/tab-bar";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<TabBar
  label="Main"
  currentPath={pathname}
  items={[
    { label: "Home", href: "/", icon: <House />, paths: ["/account"] },
    { label: "Recipes", href: "/recipes", icon: <BookOpen /> },
    { label: "Shopping", href: "/shopping-list", icon: <ShoppingBag /> },
  ]}
  accessory={{ label: "Search", href: "/search", icon: <Search /> }}
/>

Usage

The bar is fixed to the bottom of the screen, clear of the home indicator, and the component keeps its room at the end of the page, so render it last in the layout and the content scrolls clear of it. It hides from hideFrom (640px by default), where the app's Navbar or Sidebar takes over, and when printing. While the on-screen keyboard is up it slides away below the screen (with reduced motion it fades), so it never rides on the keyboard over the field a person types in, and it comes back when the keyboard goes. To hide it on a page that fills the screen (a full-screen editor, a single decision), don't render it there.

Tabs render through OuroProvider's linkComponent, so your router navigates. Each tab is 64px tall, well over the 44px a finger needs.

Use instead when

  • Use Navbar instead for the links across the top of a wider screen, Sidebar for many destinations, and Tabs for views of one page.

Props

Type: TabBarProps.

accessory
Type: TabBarItem
An item that sits apart, in a circle of its own at the end of the bar, as iOS sets Search apart from the tabs. Only its icon shows; its label names it for screen readers.
className
Type: string
Layout only: rarely needed, since the bar places itself. Appearance classes warn in development.
currentPath
Type: string
The path of the page being shown, from your router (useLocation().pathname, or usePathname() in Next.js). The tab whose href or paths match it, or else the longest one above it (/recipes for /recipes/12), gets aria-current="page" and the current look.
hideFrom
Type: "sm" | "md" | "lg" | "xl" | "never"
Default: "sm"
The breakpoint from which the bar hides, because the app's Navbar or Sidebar takes over there. never keeps it at every width.
items (required)
Type: readonly TabBarItem[]
The tabs, in order: two to five of the app's top-level pages.
label (required)
Type: string
Names the navigation for screen readers ("Main"). It is not shown.
ref
Type: Ref<HTMLElement>
Reaches the <nav>.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • children: Not rendered. The bar is items and accessory.
  • links: Not a prop. Ouro UI uses items: <TabBar label="Main" items={[{ label, href, icon }]} />.
  • pathname: Not a prop. Ouro UI uses currentPath: <TabBar currentPath={pathname}>.
  • tabs: Not a prop. Ouro UI uses items: <TabBar label="Main" items={[{ label, href, icon }]} />.
  • value: Not a prop. The current tab follows currentPath, or an item's current: <TabBar currentPath={pathname}>.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

TabBarItem

One tab in a TabBar: a page of the app, with its icon and a short label.

interface TabBarItem {
  /** The tab's text, one short word under the icon: "Recipes". Screen readers hear it as the link's name. */
  label: string;
  /** Where the tab goes. Internal paths go through `OuroProvider`'s `linkComponent` (your router's link). */
  href: string;
  /** The tab's icon, an `<svg>` (a lucide-react icon, for example). It is hidden from screen readers; the label names the tab. */
  icon: ReactNode;
  /**
   * Other paths whose pages belong to this tab, so it stays current there: a Home tab that
   * also owns `/account`. Each matches its own path and the pages under it.
   */
  paths?: readonly string[] | undefined;
  /** Marks this tab as the current page (`aria-current="page"`), when the paths cannot tell. */
  current?: boolean | undefined;
}