Skip to content
Nizivo UI

Tabs

In-page views of one subject, one panel at a time, as WAI-ARIA tabs. Only the selected panel is rendered, on the server too; keepMounted keeps a panel's state while it is hidden. When each view is a URL, use TabNav; to pick a range or a value, use SegmentedControl.

Client componentEngine: react-aria

Import

import { Tabs } from '@nizivo/react/tabs';

// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css
import '@nizivo/react/css/tabs.css';

Static or interactive

Client. It always ships React Aria's tabs code (+38,812 B gzip under Turbopack for one Tabs on the Next.js fixture). When every view is a URL, TabNav does the job with no client code. How to choose.

Direction

The strip's direction comes from the NizivoConfig scope: under a right-to-left locale (Arabic, Hebrew, Persian and so on) the layout and the arrow keys follow it, and ArrowLeft moves to the next tab. A strip whose direction differs from its locale's sets dir on Tabs.Root, which wins over the scope:

<NizivoConfig locale="de-DE">
  {/* a strip of Arabic labels inside a German page */}
  <Tabs.Root dir="rtl" defaultValue="overview">
    …
  </Tabs.Root>
</NizivoConfig>

dir takes 'ltr' or 'rtl' and is rendered as the root's dir attribute. Without it, nothing is rendered and the nearest Tabs.Root around the strip that sets dir decides, so a strip nested in its panels keys the way CSS lays it out; with none, the locale decides. Nothing reads the page's DOM, so a dir attribute on any other ancestor does not turn the arrows, and 'auto' is not accepted. The override covers the tab list's keys only: the tab labels and the panels keep the scope's locale (a number formatted in a label stays in it), and a Menu, Select or Tooltip inside a panel takes its direction from NizivoConfig.

Examples

Basic

In-page views of one list. Arrow keys move between tabs and show each panel as they land on it; Tab moves on into the panel. Only the selected panel is rendered, on the server too.

Every order from the last 90 days.

import type { JSX } from 'react';
import { Text } from '@nizivo/react/text';
import { Tabs } from '@nizivo/react/tabs';

const STATUSES = [
  ['all', 'All orders', 'Every order from the last 90 days.'],
  ['unfulfilled', 'Unfulfilled', 'Paid orders that still need to ship.'],
  ['unpaid', 'Unpaid', 'Orders waiting for a payment.'],
  ['open', 'Open', 'Orders with a pending return or exchange.'],
  ['archived', 'Archived', 'Orders you closed.'],
] as const;

export function TabsExample(): JSX.Element {
  return (
    <Tabs.Root defaultValue="all">
      <Tabs.List aria-label="Order status">
        {STATUSES.map(([value, label]) => (
          <Tabs.Tab key={value} value={value}>
            {label}
          </Tabs.Tab>
        ))}
      </Tabs.List>
      {STATUSES.map(([value, , text]) => (
        <Tabs.Panel key={value} value={value}>
          <Text tone="secondary">{text}</Text>
        </Tabs.Panel>
      ))}
    </Tabs.Root>
  );
}

States

A glyph before the label and a count after it; one tab is disabled and is skipped by the arrows.

12 orders are ready to pack.

import type { JSX } from 'react';
import { Text } from '@nizivo/react/text';
import { Tabs } from '@nizivo/react/tabs';

/** Stand-ins for `@nizivo/icons` glyphs (16 px Lucide strokes). */
function Package(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8" />
    </svg>
  );
}

function Truck(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M2 6h12v10H2zM14 9h4l4 4v3h-8" />
      <circle cx="6" cy="18" r="2" />
      <circle cx="18" cy="18" r="2" />
    </svg>
  );
}

export function TabsStatesExample(): JSX.Element {
  return (
    <Tabs.Root defaultValue="to-pack">
      <Tabs.List aria-label="Fulfilment">
        <Tabs.Tab value="to-pack" icon={<Package />} count={12}>
          To pack
        </Tabs.Tab>
        <Tabs.Tab value="shipped" icon={<Truck />} count={48}>
          Shipped
        </Tabs.Tab>
        <Tabs.Tab value="returns" count={0} disabled>
          Returns
        </Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="to-pack">
        <Text tone="secondary">12 orders are ready to pack.</Text>
      </Tabs.Panel>
      <Tabs.Panel value="shipped">
        <Text tone="secondary">48 orders shipped this week.</Text>
      </Tabs.Panel>
      <Tabs.Panel value="returns">
        <Text tone="secondary">No returns.</Text>
      </Tabs.Panel>
    </Tabs.Root>
  );
}

Manual

activationMode="manual": arrow keys only move focus and Enter or Space selects, for panels that are slow to show. keepMounted keeps a panel's state (here, a draft) while another tab is shown.

import type { JSX } from 'react';
import { Text } from '@nizivo/react/text';
import { Textarea } from '@nizivo/react/textarea';
import { Tabs } from '@nizivo/react/tabs';

export function TabsManualExample(): JSX.Element {
  return (
    <Tabs.Root defaultValue="write" activationMode="manual">
      <Tabs.List aria-label="Note">
        <Tabs.Tab value="write">Write</Tabs.Tab>
        <Tabs.Tab value="preview">Preview</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="write" keepMounted>
        <Textarea aria-label="Note text" defaultValue="Ship the blue mugs first." />
      </Tabs.Panel>
      <Tabs.Panel value="preview">
        <Text tone="secondary">The preview renders when this tab is selected.</Text>
      </Tabs.Panel>
    </Tabs.Root>
  );
}

Parts

Use the parts through the namespace (Tabs.Root) or by their flat names (TabsRoot); both work from Server Components.

Tabs.Root

Client component Renders data-slot="tabs" with the class nz-tabs. Props: TabsRootProps. Source: src/tabs/tabs.client.tsx.

NameTypeDefaultDescription
activationModeTabsActivationMode
"automatic""manual"
'automatic'
childrenrequiredReactNode–

One Tabs.List and its Tabs.Panels.

defaultValuestring–

The tab selected at first (uncontrolled). Defaults to the first tab that is not disabled.

dir"ltr" | "rtl"–

The strip's reading direction (M26), rendered as the root's dir attribute: the layout and the arrow keys follow it (in rtl, ArrowLeft moves to the next tab). Unset, nothing is rendered and the arrows follow the direction of the NizivoConfig scope's locale. Nothing reads the DOM, so a dir attribute set higher up does not turn the arrows: a strip whose direction differs from its locale's sets it here. 'auto' is not accepted for the same reason.

disabledboolean–

Disables every tab. The selected panel still shows.

onValueChange(value: string, details: ChangeDetails<TabsValueReason>) => void–

Called when the user selects a tab. details.reason is 'unknown' with React Aria.

renderRenderProp<TabsRootState>–

Must render a <div>.

valuestring–

Controlled: the selected tab's value.

  • Also takes every attribute of <div> except children, defaultValue, dir, id, onChange. (Omit<ComponentPropsWithRef<"div">, "children" | "defaultValue" | "onChange" | "id" | "dir">)

render takes an element, or, in client code, a function of the props and this state (TabsRootState):

NameTypeDefaultDescription
disabledrequiredboolean–

Tabs.List

Client component Renders data-slot="tabs-list" with the class nz-tabs-list. Props: TabsListProps. Source: src/tabs/tabs.client.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–

Tabs.Tab elements.

renderRenderProp<TabsListState>–

Must render a <div>.

  • Also takes every attribute of <div> except children, id. (Omit<ComponentPropsWithRef<"div">, "children" | "id">)

render takes an element, or, in client code, a function of the props and this state (TabsListState):

NameTypeDefaultDescription
disabledrequiredboolean–

Tabs.Tab

Client component Renders data-slot="tabs-tab" with the class nz-tabs-tab. Props: TabsTabProps. Source: src/tabs/tabs.client.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–
countReactNode–

A count after the label, such as the number of orders in a status.

disabledboolean–
iconReactNode–

A decorative glyph before the label (16 px).

renderRenderProp<TabsTabState>–

Must render a <div>.

valuerequiredstring–

Pairs the tab with the Tabs.Panel of the same value.

  • Also takes every attribute of <div> except children, id, onClick. (Omit<ComponentPropsWithRef<"div">, "children" | "id" | "onClick">)

render takes an element, or, in client code, a function of the props and this state (TabsTabState):

NameTypeDefaultDescription
disabledrequiredboolean–
focusVisiblerequiredboolean–
selectedrequiredboolean–

Tabs.Panel

Client component Renders data-slot="tabs-panel" with the class nz-tabs-panel. Props: TabsPanelProps. Source: src/tabs/tabs.client.tsx.

NameTypeDefaultDescription
childrenReactNode–
keepMountedbooleanfalse

Keeps the panel mounted, hidden, while its tab is not selected, so its state (a scroll position, a form draft) survives switching. Without it only the selected panel is rendered, on the server too.

renderRenderProp<TabsPanelState>–

Must render a <div>.

valuerequiredstring–

The value of the tab that shows this panel.

  • Also takes every attribute of <div> except children, id. (Omit<ComponentPropsWithRef<"div">, "children" | "id">)

render takes an element, or, in client code, a function of the props and this state (TabsPanelState):

NameTypeDefaultDescription
focusVisiblerequiredboolean–
selectedrequiredboolean–

The panel's tab is selected. False only on a keepMounted panel whose tab is not.

Other exports

TabsActivationMode type

import type { TabsActivationMode } from '@nizivo/react/tabs'

When a tab is selected (WAI-ARIA APG tabs). - automatic: an arrow key moves focus and selects the tab it lands on (the APG default, for panels that show without a noticeable delay); - manual: arrow keys only move focus; Enter or Space selects (for panels that are slow to show).

type TabsActivationMode = "automatic" | "manual"

TabsListProps type

import type { TabsListProps } from '@nizivo/react/tabs'

The props of Tabs.List, listed above.

TabsListState type

import type { TabsListState } from '@nizivo/react/tabs'

The render state of Tabs.List, listed above.

TabsPanelProps type

import type { TabsPanelProps } from '@nizivo/react/tabs'

The props of Tabs.Panel, listed above.

TabsPanelState type

import type { TabsPanelState } from '@nizivo/react/tabs'

The render state of Tabs.Panel, listed above.

TabsRootProps type

import type { TabsRootProps } from '@nizivo/react/tabs'

The props of Tabs.Root, listed above.

TabsRootState type

import type { TabsRootState } from '@nizivo/react/tabs'

The render state of Tabs.Root, listed above.

TabsTabProps type

import type { TabsTabProps } from '@nizivo/react/tabs'

The props of Tabs.Tab, listed above.

TabsTabState type

import type { TabsTabState } from '@nizivo/react/tabs'

The render state of Tabs.Tab, listed above.

TabsValueReason type

import type { TabsValueReason } from '@nizivo/react/tabs'

Why the selected tab changed. React Aria reports no cause, so with it details.reason is always 'unknown' (§9.3 rule 2); the names are the causes an engine that reports one would use.

type TabsValueReason = "tab-press" | "keyboard"

Data attributes and slots

Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.

SlotRendered byData attributes
tabs
.nz-tabs
Tabs.Root
root (takes render)
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
tabs-count
.nz-tabs-count
Tabs.Tab
inner element (CSS only)
none
tabs-icon
.nz-tabs-icon
Tabs.Tab
inner element (CSS only)
none
tabs-label
.nz-tabs-label
Tabs.Tab
inner element (CSS only)
none
tabs-list
.nz-tabs-list
Tabs.List
root (takes render)
  • data-disabled (present or absent)
    Mirrors the disabled attribute
tabs-panel
.nz-tabs-panel
Tabs.Panel
root (takes render)
  • data-focus-visible (present or absent)
    Focus is visible; elsewhere use :focus-visible
tabs-tab
.nz-tabs-tab
Tabs.Tab
root (takes render); interactive, focus: self; forced colours: fill on [data-selected]::before
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
  • data-focus-inset (present or absent)
    a11y-floor opt-out: draw the focus outline inside the part (outline-offset -2px), for clipped scrollers such as DialogBody and table cells (§8.5)
  • data-focus-visible (present or absent)
    Focus is visible; elsewhere use :focus-visible
  • data-selected (present or absent)
    Selected

CSS hooks

Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.

HookDefaultRead as
--nz-tabs-border--nz-border-subtle
  • border-block-end on .nz-tabs-list
--nz-tabs-count-bg--nz-bg-subtle
  • background on .nz-tabs-count
--nz-tabs-fg--nz-text-secondary
  • color on .nz-tabs-tab
--nz-tabs-fg-selected--nz-text-primary
  • color on .nz-tabs-tab[data-selected]
--nz-tabs-gap--nz-space-4
  • gap on .nz-tabs
--nz-tabs-indicator--nz-bg-brand
  • background on .nz-tabs-tab[data-selected]:before

Layers: nz.components. Classes: .nz-tabs, .nz-tabs-count, .nz-tabs-icon, .nz-tabs-label, .nz-tabs-list, .nz-tabs-panel, .nz-tabs-sizer, .nz-tabs-tab, .nz-tabs-text.

Tokens this stylesheet reads (26)

--nz-bg-brand, --nz-bg-subtle, --nz-border-focus, --nz-border-strong, --nz-border-subtle, --nz-border-width-default, --nz-border-width-focus, --nz-duration-fast, --nz-easing-standard, --nz-font-label-m, --nz-font-label-m-strong-font-weight, --nz-font-label-s, --nz-radius-full, --nz-size-control-md, --nz-size-icon-sm, --nz-space-0-5, --nz-space-1, --nz-space-1-5, --nz-space-3, --nz-space-4, --nz-text-disabled, --nz-text-primary, --nz-text-secondary, --nz-tracking-label-m, --nz-tracking-label-m-strong, --nz-tracking-label-s

Accessibility

These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:

  • tabs-tab: draws the focus outline itself on keyboard focus.

In forced-colours mode these states keep a visible system colour: tabs-tab[data-selected]::before (fill).

Behaviour comes from the react-aria engine through Nizivo's adapter: focus management, ARIA wiring and keyboard handling are the engine's.

Pattern: WAI-ARIA Authoring Practices.

Keyboard

KeysAction
TabMoves focus into the tab list, to the selected tab; the next Tab moves into the panel, to its first focusable element or the panel itself.
ArrowRight, ArrowLeftMoves focus to the next or previous tab, wrapping at the ends and skipping disabled tabs, and selects it (in manual activation, only moves focus). The keys follow the strip's reading direction, which is the dir prop on its Tabs.Root or on the nearest Tabs.Root around it, otherwise the direction of the locale set on NizivoConfig. A dir attribute on any other element higher up the page does not flip them.
Home, EndMoves focus to the first or last tab, and selects it, except in manual activation.
Enter, SpaceIn manual activation, selects the focused tab.

Source: react-aria-components 1.22.1 Tabs, TabList, Tab and TabPanel (react-aria 3.53.1 useTabList, useTab and useTabPanel) through _engines/react-aria/tabs.client.tsx. The browser lane (test/browser/tabs.spec.tsx) checks Tab entry, arrows, Home, End, wrapping, skipping a disabled tab, manual activation and the RTL flip, and that a dir on Tabs.Root overrides the NizivoConfig scope, that the nearest of nested scopes wins, that a strip nested in a dir root's panel follows that dir, that changing dir turns the arrows without remounting the tabs, and that a server render with dir hydrates without warnings.

Figma

Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.

On this page