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.

2 cups flour, 1 cup water, 1 tsp salt, 1 tsp yeast.

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-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/tabs#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/tabs

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/tabs#v1.2.13 ourostack/ouro-ui/tabs-tests#v1.2.13
import { 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 SegmentedControl instead when the choice changes how one view looks (list or grid) rather than which content shows.
  • Use Navbar or Sidebar instead for the site's own navigation.

Props

Type: TabsProps.

activation
Type: "automatic" | "manual"
Default: "automatic"
How the keyboard selects a tab with a panel. automatic selects it as soon as the arrow keys focus it, which suits panels that show at once. manual moves 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 gets aria-current="page" and the current look; with links at other paths, the longest one the path sits under (/recipes for /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 by label, 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"
horizontal puts the tabs in a row above the panel; vertical in a column beside it, from the sm breakpoint (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, when currentPath cannot tell; it wins over currentPath.

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 uses activation: <Tabs activation="manual">.
  • onChange: Not a prop. Ouro UI uses onValueChange, which gets the tab's value: <Tabs onValueChange={(value) => …}>.
  • pathname: Not a prop. Tabs that are links use currentPath: <Tabs currentPath={pathname}>.
  • tabs: Not a prop. Ouro UI uses items: <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-label when the page has more than one set of tabs. activateOnFocus selects 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 value is selected.
TabsRoot
What it is: The state owner of tabs built from parts. Use the Tabs component instead unless its items cannot 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