Tabs
Navigation – Disclosure
Tabs organize content into separate views where only one view is visible at a time.
Ready to use
The Tabs component lets users switch between different views or sections of content within the same page, without navigating away. It organises related information in a compact, accessible way — improving content discoverability and reducing cognitive load. Tabs work best when content is related but needs to be divided into smaller, manageable sections such as product details, settings, or data panels.
The Tab Picker shares this tab strip’s visual, but it chooses a value instead of switching a panel. If the click doesn’t reveal content, that’s the component you want.
Anatomy
Section titled “Anatomy”- Tabs
- Label
When to use it
Section titled “When to use it”Use Tabs when:
- You need to organise related content into separate sections within the same context.
- Users should be able to switch between different views without leaving the page.
- The pattern improves the clarity of complex or lengthy interfaces.
Avoid Tabs when:
- The sections aren’t closely related, or users need to compare content side by side.
- There are too many tabs — navigation becomes overwhelming.
- Selecting an option chooses a value rather than revealing a panel — use a Tab Picker.
Properties
Section titled “Properties”Scheme
Neutral or Contrast. The container picks up the scheme tokens so the tab item colours flip when the surrounding surface is dark — no per-tab overrides needed.
Variant
The selected tab carries a contrast fill behind it. It is painted from the selected state itself, so there is no fill variant to choose and nothing to set per tab — marking a tab selected is what gives it the fill.
State
Rested, Hover, Active, and Disabled. The active state is reinforced with a bolder weight, a coloured border and a contrast fill, so the cue isn’t carried by colour alone.
EV accent
A special border colour for the active tab when the Tabs sit inside an EV-tools surface. Use only for EV Tools — not as a general accent.
Platform considerations
Section titled “Platform considerations”Desktop
Tabs sit in a single row at the top of the panel. Keep labels short enough to fit on one line so the comparison reads at a glance.
Tablet
Same row layout. Make sure the tab row doesn’t overflow horizontally — if labels grow, consider shorter wording or a different navigation pattern.
Mobile
Tabs can scroll horizontally when there’s no room for the full set. Keep each label short so the visible portion still names the section.
Best practices
Section titled “Best practices”Use ARIA semantics so keyboard, focus, and assistive-tech behaviour come for free.
Do
Keep tab labels short, clear, and descriptive; ensure exactly one tab is selected at a time; provide visible focus indicators for keyboard users; use proper heading hierarchy within panels; pair an active visual with bold text or an underline so the cue isn’t colour-only.
Don't
Don’t mimic tab behaviour with <div> elements that lack ARIA roles, don’t allow multiple tabs to be selected at once, don’t remove inactive panels from the DOM (use hidden instead so accessibility holds), don’t auto-shift focus to the panel on tab switch (keep focus on the tab), don’t rely on colour alone to indicate the active tab, don’t let labels wrap or truncate, and don’t use tabs for unrelated or excessively long content (split it into separate pages).
Content guidelines
Section titled “Content guidelines”Tab labels should name the section they reveal, in one or two words (“Overview”, “Specifications”, “Reviews”). Keep wording parallel across the row (all nouns or all verbs) so the comparison reads coherently, use sentence case, and avoid trailing punctuation. Each panel’s first heading should match or echo the tab label so screen-reader users find their bearings when focus moves into the content.
Tabs organize content into separate views where only one view is visible at a time.
Each item is a <button role="tab"> revealing its own panel — see the
Accessibility tab for the roles and keyboard model.
The same .tng-tabs strip also backs the Tab Picker,
where each item is a <label> wrapping a native radio and nothing is navigated.
The CSS is shared; the nested markup is what differs.
<div class="tng-tabs"> <button class="is-selected">Tab 1</button> <button>Tab 2</button> <button>Tab 3</button></div>Variants
Section titled “Variants”<button class="tng-tabs-item is-ev">EV</button><button class="tng-tabs-item is-ev is-selected">EV Selected</button>States
Section titled “States”Disabled
Section titled “Disabled”<button class="tng-tabs-item" disabled>Default</button><button class="tng-tabs-item is-ev" disabled>EV</button>Selected
Section titled “Selected”Use .is-selected or aria-selected="true" to indicate the currently selected tab item. The selected item paints its contrast fill from that state alone — there is no extra class to add.
Advanced structure
Section titled “Advanced structure”You can compose our existing layouts and utility classes to create more complex tab items, such as adding images.
By default a selected tab will have all its text in bold. If you want to limit the bold text to a single element, wrap said element in .tng-tabs-item-label.
<button class="tng-tabs-item is-selected"> <div class="tng-stack align-items-center gap-sm"> <div class="align-self-start">Label</div> <div class="tng-slot is-primary"></div> <span class="tng-tabs-item-label"> Overview <span class="fg-primary-default">1</span> </span> </div></button>Overflow
Section titled “Overflow”You can use the overflow utility to wrap the tabs container and enable horizontal scrolling when there are too many tabs to fit in the available space.
<div class="tng-overflow-scroll-indicators" data-overflow-start="true" data-overflow-end="true"> <i class="tng-icon icon-chevron-left" aria-hidden="true"></i> <div class="tng-overflow-scroll has-hidden-scrollbar is-inline" style="max-inline-size: 250px" > <div class="tng-tabs"> <button class="tng-tabs-item is-selected">Tab 1</button> <button class="tng-tabs-item">Tab 2</button> <button class="tng-tabs-item">Tab 3</button> <button class="tng-tabs-item">Tab 4</button> <button class="tng-tabs-item">Tab 5</button> </div> </div> <i class="tng-icon icon-chevron-right" aria-hidden="true"></i></div>Recipes
Section titled “Recipes”Overview content goes here.
Features content goes here.
Performance content goes here.
Specifications content goes here.
Safety content goes here.
Warranty content goes here.
Charging content goes here.
Import the Tabs component from @tmedxp/react-components.
Properties
Section titled “Properties”TabsProperties extends ComponentProps<'div'> (excluding onChange and className), meaning it includes all standard HTML attributes that can be applied to a <div>.
| Prop | Type | Description | Required |
|---|---|---|---|
items |
TabItemData[] |
Array of tab items to render. | ✅ |
tabListAriaLabel |
string |
Accessible label for the tablist container. | ✅ |
defaultValue |
string |
Initially selected tab value (uncontrolled mode). | |
activeValue |
string |
Currently selected tab value (controlled mode). | |
onChange |
(value: string) => void |
Callback fired when the selected tab changes. | |
variant |
'default' | 'filled' | 'ev' | 'ev-filled' |
Visual variant. Default is 'default'. |
|
controls |
ReactNode | ((props: TabControlsRenderProperties) => ReactNode) |
Custom controls replacing the default overflow navigation. | |
hideControls |
boolean |
Explicitly hide or show overflow controls. Auto-shown when items > 3. | |
idPrefix |
string |
Prefix for generated tab/panel IDs. Falls back to useId(). |
|
className |
ClassValue |
Custom class names for the wrapper element. | |
scrollPreviousAriaLabel |
string |
Accessible label for the scroll-previous button. | |
scrollNextAriaLabel |
string |
Accessible label for the scroll-next button. |
TabItemData
Section titled “TabItemData”Each item in the items array follows this shape:
| Prop | Type | Description | Required |
|---|---|---|---|
title |
string |
Tab label text. | ✅ |
value |
string |
Unique identifier for the tab. | ✅ |
content |
ReactNode |
Panel content rendered when the tab is active. | |
label |
string |
Category label displayed above the title. | |
image |
ReactNode |
Image or media displayed in the tab item. | |
badge |
string | number |
Badge counter displayed after the title. | |
disabled |
boolean |
Disables the tab (skipped during keyboard navigation). | |
isLoading |
boolean |
Shows loading state in the panel instead of content. | |
loadingContent |
ReactNode |
Custom loading indicator (defaults to “Loading content…”). |
TabControlsRenderProperties
Section titled “TabControlsRenderProperties”When passing a render function to controls, it receives:
| Prop | Type | Description |
|---|---|---|
activeValue |
string |
Currently selected tab value. |
activeIndex |
number |
Index of the active tab among enabled items. |
enabledItems |
TabItemData[] |
Filtered list of non-disabled items. |
items |
TabItemData[] |
Full items array. |
scrollIndex |
number |
Current carousel scroll snap index. |
scrollCount |
number |
Total number of scroll snap points. |
canScrollPrevious |
boolean |
Whether scrolling backward is possible. |
canScrollNext |
boolean |
Whether scrolling forward is possible. |
selectTab |
(value: string) => void |
Select a tab by value. |
selectPreviousTab |
() => void |
Select the previous enabled tab. |
selectNextTab |
() => void |
Select the next enabled tab. |
Examples
Section titled “Examples”Basic usage
Section titled “Basic usage”import { Tabs } from '@tmedxp/react-components';
const BasicTabs = () => { const items = [ { title: 'Tab 1', value: 'tab-1', content: 'Tab 1 content goes here.' }, { title: 'Tab 2', value: 'tab-2', content: 'Tab 2 content goes here.' }, { title: 'Tab 3', value: 'tab-3', content: 'Tab 3 content goes here.' }, ];
return ( <Tabs items={items} defaultValue="tab-1" tabListAriaLabel="Example tabs" /> );};
export { BasicTabs };Advanced structure
Section titled “Advanced structure”import { Tabs } from '@tmedxp/react-components';
const AdvancedTabs = () => { const items = [ { label: 'Category', title: 'Overview', value: 'overview', badge: 1, image: <img src="/models/yaris.webp" alt="" />, content: 'Overview content goes here.', }, { label: 'Category', title: 'Features', value: 'features', badge: 3, image: <img src="/models/corolla.webp" alt="" />, content: 'Features content goes here.', }, { label: 'Category', title: 'Specifications', value: 'specifications', image: <img src="/models/camry.webp" alt="" />, content: 'Specifications content goes here.', }, ];
return ( <Tabs items={items} defaultValue="overview" tabListAriaLabel="Advanced tabs with labels, images and badge counters" /> );};
export { AdvancedTabs };With custom controls
Section titled “With custom controls”Use a render function in controls to replace the default overflow chevrons with a count indicator or any custom navigation.
import { CountIndicator, Tabs } from '@tmedxp/react-components';
const TabsWithCountIndicator = () => { const items = [ { title: 'Yaris', value: 'yaris', content: '…' }, { title: 'Corolla', value: 'corolla', content: '…' }, { title: 'Camry', value: 'camry', content: '…' }, { title: 'RAV4', value: 'rav4', content: '…' }, { title: 'Highlander', value: 'highlander', content: '…' }, ];
return ( <Tabs items={items} defaultValue="yaris" tabListAriaLabel="Toyota car models" controls={({ scrollIndex, scrollCount, selectPreviousTab, selectNextTab, }) => ( <CountIndicator index={scrollIndex} total={scrollCount} prevAriaLabel="Previous tab" nextAriaLabel="Next tab" helperOfLabel="of" onPrev={selectPreviousTab} onNext={selectNextTab} /> )} /> );};
export { TabsWithCountIndicator };EV variant
Section titled “EV variant”import { Tabs } from '@tmedxp/react-components';
const EvTabs = () => { const items = [ { title: 'Charging', value: 'charging', content: 'Charging info.' }, { title: 'Range', value: 'range', content: 'Range details.' }, { title: 'Battery', value: 'battery', content: 'Battery specs.' }, ];
return ( <Tabs items={items} defaultValue="charging" variant="ev" tabListAriaLabel="EV tools tabs" /> );};
export { EvTabs };Disabled items
Section titled “Disabled items”import { Tabs } from '@tmedxp/react-components';
const TabsWithDisabled = () => { const items = [ { title: 'Available', value: 'available', content: 'Available content.', }, { title: 'Coming soon', value: 'coming-soon', disabled: true, content: '', }, { title: 'Also available', value: 'also-available', content: 'More content.', }, ];
return ( <Tabs items={items} defaultValue="available" tabListAriaLabel="Tabs with disabled option" /> );};
export { TabsWithDisabled };Loading state
Section titled “Loading state”import { Tabs } from '@tmedxp/react-components';
const TabsWithLoading = () => { const items = [ { title: 'Loaded', value: 'loaded', content: 'Content ready.' }, { title: 'Loading', value: 'loading', isLoading: true, loadingContent: <span>Fetching content...</span>, content: 'This appears after loading completes.', }, ];
return ( <Tabs items={items} defaultValue="loaded" tabListAriaLabel="Tabs with loading state" /> );};
export { TabsWithLoading };The Tabs component follows the WAI-ARIA Tabs pattern — a tablist whose tabs each reveal a panel — and meets WCAG 2.1 AA. An accessible tabs strip stays reachable and operable from the keyboard, keeps assistive technology informed of which tab is selected and which panel it controls, and gives every element an accessible name — so the current view is clear whether the user is looking at it, listening to it, or tabbing through it. The Tab Picker wears the same visual but is a radio group, and none of this page’s roles or keys apply to it.
For designers
Section titled “For designers”- All states (rested, hover, active, disabled) must maintain accessible contrast on every supported surface. Source: WCAG 1.4.3 Contrast (Minimum).
- Selection must never rely on colour alone — reinforce it with the emphasised label weight and the active border, so it survives for colour-blind users. Source: WCAG 1.4.1 Use of Color.
- Focus styles must stay visible, meet contrast requirements on every supported surface, and be clearly distinguishable from hover. Source: WCAG 2.4.7 Focus Visible.
- Use semantic colour tokens (e.g.
foreground/neutral/default) rather than fixed values, so the component adapts correctly across themes and surfaces.
For developers
Section titled “For developers”Tabs follow the WAI-ARIA Tabs pattern: a tablist of tab buttons, each revealing its own panel, with inactive panels kept in the DOM under the hidden attribute so the tab-to-panel relationship survives. The recipe uses auto-activation — moving to a tab with the arrow keys selects it and reveals its panel in one step — and keeps focus on the tab rather than shifting it into the panel.
Pick the pattern by what the component means, not how it looks: if the strip chooses a value rather than revealing a panel, it is a Tab Picker and needs a radio group instead of this one.
The per-element roles, names, and states are in the Labelling elements section below.
Keyboard interaction
Section titled “Keyboard interaction”The tablist is fully operable from the keyboard, with auto-activation. Source: WCAG 2.1.1 Keyboard.
| Key | Action |
|---|---|
| ← / → | Move to and activate the previous / next tab |
| Home / End | Move to and activate the first / last tab |
| Enter / Space | Activate the focused tab |
| Tab | Move focus out of the tablist to the next control |
Interactive targets must be large enough for comfortable touch use, comfortably clearing the WCAG AA minimum. Source: WCAG 2.5.8 Target Size (Minimum).
Focus order
Section titled “Focus order”Tab lands on the focused tab — a roving tabindex keeps only that one tab in the page tab sequence while the arrow keys rove between the others — then a further Tab leaves the tablist for the next control. The keys that drive this are in the Keyboard interaction table above.
Overview content goes here.
- Focused tab
Source: WCAG 2.4.3 Focus Order.
Labelling elements
Section titled “Labelling elements”Give every element the role, name, and state assistive technology needs.
Tab list
role="tablist" on the strip. Name it with aria-label (the recipe uses aria-label="Content tabs") or aria-labelledby, so it doesn’t announce as an unnamed group.
Tab
role="tab" on each <button>, with aria-selected kept in sync (true on the selected tab, false on the rest) and aria-controls pointing at the panel it reveals. Each tab takes its accessible name from its text content — keep labels short and self-describing.
Tab panel
role="tabpanel", named back to its tab with aria-labelledby, and hidden while inactive. Give it tabindex="0" when it holds no focusable content, so keyboard users can still reach the panel.
Source: WCAG 4.1.2 Name, Role, Value.