Navigation
Tabs
Tabs that switch between panels in place: an accessible tab list with roving focus, arrow-key navigation, a line that slides to the selected tab, and 44px tabs on touch screens. Pass the tabs and their panels as items. Under forced colours (Windows contrast themes) each tab keeps a border and the selected one is filled with Highlight.
In Spoonjoy
Spoonjoy, a recipe app, is built with Ouro HX (how).
See it live, no sign-in needed: Search
In its source: app/components/navigation/recipes-section-nav.tsx · app/routes/search.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/tabs#v1.2.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/tabsIts 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/tabs#v1.2.13 ourostack/ouro-ui/tabs-tests#v1.2.13import { Tabs } from "@/components/ouro/tabs/tabs";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<Tabs
label="Recipe"
items={[
{ value: "ingredients", label: "Ingredients", content: <IngredientList /> },
{ value: "method", label: "Method", content: <Steps /> },
]}
/>
<Tabs
label="Recipes"
currentPath={location.pathname}
items={[
{ value: "all", label: "All", href: "/recipes" },
{ value: "mine", label: "Mine", href: "/my-recipes" },
]}
/>Usage
When each tab is a view of one page with a URL of its own (a section's pages, a search's scopes), give each item an href instead of content: the tabs render as a <nav> of links in the same look, through your router's link, with the current one marked aria-current="page" from currentPath, and Tab moves between them as between any links.
Use the parts in tabs-parts when items cannot express your layout.
Use instead when
- Use
SegmentedControlinstead when the choice changes how one view looks (list or grid) rather than which content shows. - Use
NavbarorSidebarinstead for the site's own navigation.
Props
Type: TabsProps.
activation- Type:
"automatic" | "manual" - Default:
"automatic" - How the keyboard selects a tab with a panel.
automaticselects it as soon as the arrow keys focus it, which suits panels that show at once.manualmoves focus only and waits for Enter or Space, for panels that load slowly. className- Type:
string - Layout only: margins, width, grid placement (
mt-6). Appearance classes warn in development. currentPath- Type:
string - Links only: the path of the page being shown, from your router, with its query when the tabs differ by query (
location.pathname + location.search). The link at that path whose parameters the path carries getsaria-current="page"and the current look; with links at other paths, the longest one the path sits under (/recipesfor/recipes/12). defaultValue- Type:
string - Default: the first enabled item
- The tab selected first, when the component keeps its own state. Panels only.
items(required)- Type:
readonly TabsItem[] | readonly TabsLinkItem[] - The tabs, in order: each with the panel it shows (
content), or each a link to its own page (href). Links render as a<nav>of links named bylabel, and Tab moves between them as between any links. label(required)- Type:
string - Names the tab list for screen readers ("Recipe sections"), or the nav of links. It is not shown: the tabs' own labels are the visible text.
onValueChange- Type:
((value: string) => void) - Called with the new tab's value when the person selects a tab. Panels only: a link opens its page.
orientation- Type:
"vertical" | "horizontal" - Default:
"horizontal" horizontalputs the tabs in a row above the panel;verticalin a column beside it, from thesmbreakpoint (640px) up, and in a row on a phone. With panels, the arrow keys follow: left and right in a row, up and down in a column.ref- Type:
Ref<HTMLDivElement> - Reaches the rendered element.
value- Type:
string - The selected tab's value, when you control it. Pair it with
onValueChange. With links, the current page's value, whencurrentPathcannot tell; it wins overcurrentPath.
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.
activateOnFocus: Not a prop. Ouro UI usesactivation:<Tabs activation="manual">.onChange: Not a prop. Ouro UI usesonValueChange, which gets the tab's value:<Tabs onValueChange={(value) => …}>.pathname: Not a prop. Tabs that are links usecurrentPath:<Tabs currentPath={pathname}>.tabs: Not a prop. Ouro UI usesitems:<Tabs items={[{ value, label, content }]}>.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: Tabs merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes
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.
Parts
When these props cannot express your layout, build it from the parts in @/components/ouro/tabs/tabs-parts. The nesting they need follows.
import {
TabsIndicator,
TabsList,
TabsPanel,
TabsRoot,
TabsTab,
} from "@/components/ouro/tabs/tabs-parts";Required nesting
<TabsRoot defaultValue="ingredients">
<TabsList aria-label="Recipe">
<TabsTab value="ingredients">Ingredients</TabsTab>
<TabsTab value="method">Method</TabsTab>
<TabsIndicator />
</TabsList>
<TabsPanel value="ingredients">…</TabsPanel>
<TabsPanel value="method">…</TabsPanel>
</TabsRoot>Each part checks that it sits inside <TabsRoot>: outside it, a part throws in development with a message that shows this JSX shape.tabs-nesting
TabsIndicator- What it is: The line that slides to the selected tab. Put it last inside
TabsList. TabsList- What it is: The row (or column) of tabs. Name it with
aria-labelwhen the page has more than one set of tabs.activateOnFocusselects a tab when the arrow keys focus it. A row that scrolls, on a phone, fades out on the side that hides more tabs. A key that moves the selection switches tabs without animation. TabsPanel- What it is: The content shown while the tab with the same
valueis selected. TabsRoot- What it is: The state owner of tabs built from parts. Use the
Tabscomponent instead unless itsitemscannot express your layout, such as a tab that holds a badge next to its label or a panel shared by two tabs. TabsTab- What it is: One tab, a button that shows the panel with the same
value.
Types
TabsItem
One tab and its panel.
interface TabsItem {
/** Identifies the tab: `value`, `defaultValue` and `onValueChange` use it. Unique within the tabs. */
value: string;
/** The tab's text. Keep it short: one or two words. */
label: ReactNode;
/** What the panel shows while this tab is selected. */
content: ReactNode;
/** Shows the tab but keeps it from being selected. */
disabled?: boolean | undefined;
/** Not set: a tab with a panel. Give every item an `href`, and no `content`, for tabs that are links. */
href?: never;
}TabsLinkItem
One tab that is a link to a page of its own.
interface TabsLinkItem {
/** Identifies the tab: `value` names the current one with it. Unique within the tabs. */
value: string;
/** The tab's text. Keep it short: one or two words. */
label: ReactNode;
/**
* The page the tab opens. Internal paths render through `OuroProvider`'s `linkComponent`
* (your router's link).
*/
href: string;
/** Not set: the page at `href` is the tab's content. */
content?: never;
/** A link cannot be disabled. Leave the item out instead. */
disabled?: never;
}Claim sources
- layout-classes: test:registry/ouro/components/ouro/tabs/tabs.test.tsx::Tabs > lets app layout classes win
verify:testExists(registry/ouro/components/ouro/tabs/tabs.test.tsx::Tabs > lets app layout classes win)