Skip to content

Tabs

Edit on GitHub
@volvo-cars/css v2.3.0@volvo-cars/react-headless v0.24.7

Tabs organise content into sections that users can switch between. Use the useTabList, useTab, and useTabPanel hooks from @volvo-cars/react-headless together with the .tablist and .tab CSS classes.

Overview content goes here.
import type { TabGroupState } from '@volvo-cars/react-headless';
import { useTab, useTabList, useTabPanel } from '@volvo-cars/react-headless';
const tabs = [
{ title: 'Overview', content: 'Overview content goes here.' },
{ title: 'Interior', content: 'Interior content goes here.' },
{ title: 'Features', content: 'Features content goes here.' },
];
function Tab({
state,
index,
title,
}: {
state: TabGroupState;
index: number;
title: string;
}) {
const { tabProps } = useTab({ state, index });
return (
<button {...tabProps} className="tab">
{title}
</button>
);
}
export function BasicTabs() {
const { tabListProps, tabGroupState, selectedIndex } = useTabList();
const { tabPanelProps } = useTabPanel({
state: tabGroupState,
index: selectedIndex,
});
return (
<div>
<div {...tabListProps} aria-label="Car sections" className="tablist">
{tabs.map(({ title }, index) => (
<Tab key={title} state={tabGroupState} index={index} title={title} />
))}
</div>
<div {...tabPanelProps} className="py-24">
{tabs[selectedIndex].content}
</div>
</div>
);
}

Use the tablist-stretched class instead of tablist to make tabs fill the available width. Best suited for 2–4 tabs with labels of similar length.

Overview content goes here.
import type { TabGroupState } from '@volvo-cars/react-headless';
import { useTab, useTabList, useTabPanel } from '@volvo-cars/react-headless';
const tabs = [
{ title: 'Overview', content: 'Overview content goes here.' },
{ title: 'Interior', content: 'Interior content goes here.' },
{ title: 'Features', content: 'Features content goes here.' },
];
function Tab({
state,
index,
title,
}: {
state: TabGroupState;
index: number;
title: string;
}) {
const { tabProps } = useTab({ state, index });
return (
<button {...tabProps} className="tab">
{title}
</button>
);
}
export function StretchedTabs() {
const { tabListProps, tabGroupState, selectedIndex } = useTabList();
const { tabPanelProps } = useTabPanel({
state: tabGroupState,
index: selectedIndex,
});
return (
<div>
<div
{...tabListProps}
aria-label="Car sections"
className="tablist-stretched"
>
{tabs.map(({ title }, index) => (
<Tab key={title} state={tabGroupState} index={index} title={title} />
))}
</div>
<div {...tabPanelProps} className="py-24">
{tabs[selectedIndex].content}
</div>
</div>
);
}

Pass orientation: 'vertical' to useTabList and let the .tablist CSS handle the layout. Vertical tabs render a border on the inline-end side by default — add data-placement="start" to move it to the inline-start side.

Overview content goes here.
import type { TabGroupState } from '@volvo-cars/react-headless';
import { useTab, useTabList, useTabPanel } from '@volvo-cars/react-headless';
const tabs = [
{ title: 'Overview', content: 'Overview content goes here.' },
{ title: 'Interior', content: 'Interior content goes here.' },
{ title: 'Features', content: 'Features content goes here.' },
];
function Tab({
state,
index,
title,
}: {
state: TabGroupState;
index: number;
title: string;
}) {
const { tabProps } = useTab({ state, index });
return (
<button {...tabProps} className="tab">
{title}
</button>
);
}
export function VerticalTabs() {
const { tabListProps, tabGroupState, selectedIndex } = useTabList({
orientation: 'vertical',
});
const { tabPanelProps } = useTabPanel({
state: tabGroupState,
index: selectedIndex,
});
return (
<div className="flex">
<div
{...tabListProps}
aria-label="Car sections"
className="tablist"
aria-orientation="vertical"
>
{tabs.map(({ title }, index) => (
<Tab key={title} state={tabGroupState} index={index} title={title} />
))}
</div>
<div {...tabPanelProps} className="px-24 flex-grow">
{tabs[selectedIndex].content}
</div>
</div>
);
}

The default selected indicator uses the primary foreground colour. Set data-color="accent" on the tablist element to switch to accent blue.

When tabs link to different pages, use <a> elements with aria-current="page" instead of the tab hooks. This variant doesn’t need tab panels.

<nav aria-label="Page navigation">
<ul class="tablist">
<li>
<a href="#overview" class="tab" aria-current="page">Overview</a>
</li>
<li>
<a href="#interior" class="tab">Interior</a>
</li>
<li>
<a href="#specifications" class="tab">Specifications</a>
</li>
</ul>
</nav>

The hooks implement the WAI-ARIA Tabs pattern:

  • Arrow Left / Right — move focus between horizontal tabs and auto-select.
  • Arrow Up / Down — move focus between vertical tabs and auto-select.
  • Home / End — jump to first / last tab.
  • Tab key — moves focus into the active tab panel, not to the next tab.

Key consumer responsibilities from the WCAG audit:

  • ARIA rolesuseTabList sets role="tablist" and aria-orientation. useTab sets role="tab" and aria-selected. useTabPanel sets role="tabpanel" and aria-labelledby. For CSS-only usage, add these attributes manually.
  • Labelling — provide aria-label on the tablist element (e.g. "Page navigation").
  • Focus management — roving tabIndex is handled by the hooks. Only the active tab has tabIndex={0}.
  • On Input — avoid triggering unrelated side effects when switching tabs.

See the useTabs hooks documentation for full API reference, options, and return values for useTabList, useTab, and useTabPanel.