Skip to content
design
Components

Tabs

A row of tabs, with the selected one underlined, and the panel each one shows.

On this page
Falmouth to A CoruñaDeparts in 2 days
Tuesday
Northwest 12 to 16 kn, sea 1.5 m, 1018 hPa rising
Wednesday
North 15 to 20 kn, gusts 26 kn, sea 2.5 m in the Bay
Thursday
Northeast 10 to 14 kn, sea 1.8 m easing
A passage plan in six tabs, each tab naming the panel it shows below the row.
Code
import { Badge } from "@bosunkit/components/badge";
import { DescriptionList, type DescriptionListItem } from "@bosunkit/components/description-list";
import { Tabs, type TabsOption } from "@bosunkit/components/tabs";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A passage plan from Falmouth to A Coruña in six tabs, each naming the panel it shows below the row: " +
  "overview, legs, weather, tides, watches and the departure checklist.";

const TABS: readonly TabsOption[] = [
  { id: "overview", label: "Overview" },
  { id: "legs", label: "Legs" },
  { id: "weather", label: "Weather" },
  { id: "tides", label: "Tides" },
  { id: "watches", label: "Watches" },
  { id: "checklist", label: "Checklist" },
];

type Row = readonly [string, string];

const VIEWS: Readonly<Record<string, readonly Row[]>> = {
  overview: [
    ["Route", "Falmouth to A Coruña across the Bay of Biscay"],
    ["Distance", "412 NM in 3 legs"],
    ["Departure", "Tuesday 13 October, 06:00 UTC"],
    ["Arrival", "Thursday 15 October, about 19:00 UTC at 6.8 kn"],
    ["Crew", "4 aboard, 2 on each watch"],
  ],
  legs: [
    ["Leg 1", "Falmouth to off Ushant, 98 NM, 14 h 25 min"],
    ["Leg 2", "Ushant to 45°N 008°W, 182 NM, 26 h 45 min"],
    ["Leg 3", "45°N 008°W to A Coruña, 132 NM, 19 h 25 min"],
  ],
  weather: [
    ["Tuesday", "Northwest 12 to 16 kn, sea 1.5 m, 1018 hPa rising"],
    ["Wednesday", "North 15 to 20 kn, gusts 26 kn, sea 2.5 m in the Bay"],
    ["Thursday", "Northeast 10 to 14 kn, sea 1.8 m easing"],
  ],
  tides: [
    ["Falmouth HW", "05:12 UTC, 4.9 m"],
    ["Falmouth LW", "11:31 UTC, 1.1 m"],
    ["Ushant stream", "Fair from 08:40 UTC, up to 3.5 kn at springs"],
  ],
  watches: [
    ["00:00 to 04:00", "Marta Lindqvist and Joost Bakker"],
    ["04:00 to 08:00", "Anneke de Vries and Femke Smit"],
    ["08:00 to 12:00", "Marta Lindqvist and Joost Bakker"],
  ],
  checklist: [
    ["Engine", "Oil, coolant and raw-water strainer checked"],
    ["Safety", "Liferaft certificate valid to March 2027; jacklines rigged"],
    ["Navigation", "Route loaded in the plotter; paper chart 2675 aboard"],
    ["Open", "Fill the water tanks; 2 of 12 items left"],
  ],
};

export default function Showcase(): ReactElement {
  const [active, setActive]: [string, Dispatch<SetStateAction<string>>] = useState<string>("weather");

  return (
    <div style={{ width: "100%", maxWidth: 640 }}>
      <div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)", marginBottom: "var(--space-sm)" }}>
        <strong style={{ fontFamily: "var(--font-mono)", fontSize: 13 }}>Falmouth to A Coruña</strong>
        <Badge variant="amber">Departs in 2 days</Badge>
      </div>
      <Tabs tabs={TABS} value={active} onValueChange={setActive}>
        {TABS.map((tab: TabsOption): ReactElement => (
          <Tabs.Panel key={tab.id} value={tab.id}>
            <div style={{ paddingTop: "var(--space-md)" }}>
              <DescriptionList
                items={(VIEWS[tab.id] ?? []).map((row: Row): DescriptionListItem => ({ term: row[0], value: row[1] }))}
              />
            </div>
          </Tabs.Panel>
        ))}
      </Tabs>
    </div>
  );
}

Tabs switch between views of one subject. The selected tab is underlined in the accent color, and the others are dim until the pointer is over them. The tabs are buttons with the tab role in a tablist, and the selected one is marked with aria-selected. Use value and onValueChange for controlled selection, or defaultValue for internal state. Change callbacks retain Base UI event details and cancellation.

Compose Tabs.Root, Tabs.List, Tabs.Tab and Tabs.Panel; each tab and panel share a value. The options-based Tabs recipe assembles the list. The tab names its panel with aria-controls and the panel its tab with aria-labelledby, so a screen reader announces which view a tab opens and which tab a view belongs to. A panel shows its content only while its tab is selected; one with nothing focusable inside takes focus itself, so the keyboard reaches its text from the tab list.

Only the selected tab is in the page's tab order. The arrow keys move between the tabs, wrapping at the ends, and Home and End go to the first and the last. With activateOnFocus, the recipe's default, a tab is selected as focus reaches it; with activateOnFocus={false} focus only moves, and Enter or Space selects. On a phone or a tablet (a screen up to the 68.75em breakpoint), under a coarse pointer and at the helm size a tab is at least --target-touch-size tall, a thumb's target.

Examples

Two tabs

The selected tab is underlined in the accent color; selecting the other slides the underline across to it.
Code
import { Tabs, type TabsOption } from "@bosunkit/components/tabs";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

const TABS: readonly TabsOption[] = [
  { id: "templates", label: "Templates" },
  { id: "runs", label: "Runs" },
];

export const description: string =
  "The selected tab is underlined in the accent color; selecting the other slides the underline across to it.";

export default function TwoTabs(): ReactElement {
  const [active, setActive]: [string, Dispatch<SetStateAction<string>>] = useState<string>("templates");
  return <Tabs tabs={TABS} value={active} onValueChange={setActive} />;
}

Manual activation

6 log entries since 06:00 UTC.

Manual activation: the arrow keys move between the tabs and Enter or Space selects, so a slow log is not loaded for every tab the focus passes.
Code
import { Tabs, type TabsOption } from "@bosunkit/components/tabs";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Manual activation: the arrow keys move between the tabs and Enter or Space selects, so a slow log is not loaded " +
  "for every tab the focus passes.";

const TABS: readonly TabsOption[] = [
  { id: "today", label: "Today" },
  { id: "passage", label: "This passage" },
  { id: "season", label: "This season" },
];

const ENTRIES: Readonly<Record<string, string>> = {
  today: "6 log entries since 06:00 UTC.",
  passage: "41 log entries since Falmouth.",
  season: "312 log entries since April.",
};

export default function ManualActivation(): ReactElement {
  const [active, setActive]: [string, Dispatch<SetStateAction<string>>] = useState<string>("today");
  return (
    <div style={{ width: "100%", maxWidth: 480 }}>
      <Tabs tabs={TABS} value={active} onValueChange={setActive} activateOnFocus={false}>
        {TABS.map((tab: TabsOption): ReactElement => (
          <Tabs.Panel key={tab.id} value={tab.id}>
            <p style={{ margin: 0, paddingTop: "var(--space-md)" }}>{ENTRIES[tab.id]}</p>
          </Tabs.Panel>
        ))}
      </Tabs>
    </div>
  );
}

Without motion

The underline slides by default: an indicator under the selected tab moves to the next one the crew selects, over the medium motion duration on the standard curve, so the eye follows the selection across the row. Under reduced motion the indicator stays and moves at once. motion={false} draws the underline as the selected tab's own border, which jumps.

With `motion={false}` the selected tab's own border is the underline, and it jumps to the next tab selected.
Code
import { Tabs, type TabsOption } from "@bosunkit/components/tabs";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

const TABS: readonly TabsOption[] = [
  { id: "passage", label: "Passage" },
  { id: "weather", label: "Weather" },
  { id: "tides", label: "Tides" },
  { id: "log", label: "Log" },
];

export const description: string =
  "With `motion={false}` the selected tab's own border is the underline, and it jumps to the next tab selected.";

export default function WithoutMotion(): ReactElement {
  const [active, setActive]: [string, Dispatch<SetStateAction<string>>] = useState<string>("passage");
  return <Tabs tabs={TABS} value={active} onValueChange={setActive} motion={false} />;
}

Props

Tabs

The tab list: the tabs, the selected one, and how the keyboard selects.

PropTypeDefaultDescription
tabsrequiredreadonly TabsOption[]–Ordered tabs in the assembled recipe; use Tabs.Root for child composition.
valuestring | undefined–Selected identifier.
defaultValuestring | undefined–Initial identifier when uncontrolled; defaults to the first enabled tab.
onValueChange((value: string, details: Primitive.Root.ChangeEventDetails) => void) | undefined–Reports selection with the original cancellable Base UI event details.
activateOnFocusboolean | undefined–Select when arrow keys move focus; true in this recipe. Tabs.List defaults to false.
labelstring | undefined–Tab list's accessible name.
classNamestring | undefined–Additional tab list classes.
motionboolean | undefined–Show the selection indicator.
renderReact.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined–Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
styleReact.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined–Style applied to the element, or a function that returns a style object based on the component's state.
orientationTabsRoot.Orientation | undefined–The component orientation (layout flow direction).

Panel

The view one tab shows, inside Tabs.

PropTypeDefaultDescription
classNamestring | ((state: State) => string | undefined) | undefined–CSS class applied to the element, or a function that returns a class based on the component's state.
renderReact.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined–Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
styleReact.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined–Style applied to the element, or a function that returns a style object based on the component's state.
valuerequiredTabsTab.Value–The value of the TabPanel. It will be shown when the Tab with the corresponding value is active.
keepMountedboolean | undefined–Whether to keep the HTML element in the DOM while the panel is hidden.

Also takes every attribute of Primitive.Panel.Props.

Do
Use tabs for views of one subject, such as the log's entries for this watch and for the passage.
Don't
Don't use tabs to move between pages. A page is a link in the navigation list, which a browser can open in a new tab and the router follows.
  • Navigation: distinguish peer views from routes and workflow stages.

  • Accessibility: target sizes and the keyboard path.

  • Color: the accent that marks the selected tab.