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.
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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/tab-barIts 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.13import { 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
Navbarinstead for the links across the top of a wider screen,Sidebarfor many destinations, andTabsfor 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
labelnames 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, orusePathname()in Next.js). The tab whosehreforpathsmatch it, or else the longest one above it (/recipesfor/recipes/12), getsaria-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
NavbarorSidebartakes over there.neverkeeps 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 isitemsandaccessory.links: Not a prop. Ouro UI usesitems:<TabBar label="Main" items={[{ label, href, icon }]} />.pathname: Not a prop. Ouro UI usescurrentPath:<TabBar currentPath={pathname}>.tabs: Not a prop. Ouro UI usesitems:<TabBar label="Main" items={[{ label, href, icon }]} />.value: Not a prop. The current tab followscurrentPath, or an item'scurrent:<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;
}