Tabs
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.
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> );}Stretched
Section titled “Stretched”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.
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> );}Vertical
Section titled “Vertical”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.
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.
Tabs as navigation links
Section titled “Tabs as navigation links”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>Keyboard navigation
Section titled “Keyboard navigation”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.
Accessibility
Section titled “Accessibility”Key consumer responsibilities from the WCAG audit:
- ARIA roles —
useTabListsetsrole="tablist"andaria-orientation.useTabsetsrole="tab"andaria-selected.useTabPanelsetsrole="tabpanel"andaria-labelledby. For CSS-only usage, add these attributes manually. - Labelling — provide
aria-labelon the tablist element (e.g."Page navigation"). - Focus management — roving
tabIndexis handled by the hooks. Only the active tab hastabIndex={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.