Tabs Pattern for Switching Table and Chart Views
Permalink to "Tabs Pattern for Switching Table and Chart Views"“Table” and “Chart” buttons above a data visualisation are one of the most common view switchers in data products — and one of the best accessibility features a chart can have, because the table view gives screen reader users full access to the numbers. Implemented as two loose buttons, though, they leave users unsure which view is active and what changed. The ARIA tabs pattern gives the switcher a clear structure: a list of tabs, one selected, each controlling a labelled panel.
This guide belongs to tabs & view switchers for data views.
Spec reference
Permalink to "Spec reference"- WAI-ARIA tabs pattern —
role="tablist"containingrole="tab"elements witharia-selectedandaria-controls; eachrole="tabpanel"hasaria-labelledbypointing at its tab. Only the selected tab is in the tab order; Left and Right arrows move between tabs; Home and End go to first and last. - Activation — automatic (arrow moves and activates) or manual (arrow moves; Enter or Space activates). Manual activation is recommended when showing a panel is slow.
- SC 4.1.2 Name, Role, Value (A) — selected state exposed.
- SC 1.3.1 Info and Relationships (A) — panel labelled by its tab.
- SC 2.4.3 Focus Order (A) — Tab from the selected tab moves into its panel.
When to use — and when not to
Permalink to "When to use — and when not to"Use tabs when several views of the same data, or closely related data, share one area and only one is visible at a time: table and chart, chart and summary, monthly and quarterly breakdowns.
Do not use tabs for navigation between pages — that is a navigation landmark with links, even if it looks like tabs. Do not use tabs when users need to compare views side by side; show both.
The misapplication: two toggle buttons with aria-pressed, one per view, styled as tabs. That works, but users get no “1 of 2” structure, both buttons are tab stops, and the panel has no name. Tabs communicate the relationship better.
Annotated code example
Permalink to "Annotated code example"<div role="tablist" aria-label="Revenue by region views"> <!-- SC 1.3.1 -->
<button role="tab" id="tab-chart" aria-selected="true" aria-controls="panel-chart" tabindex="0">Chart</button>
<button role="tab" id="tab-table" aria-selected="false" aria-controls="panel-table" tabindex="-1">Table</button>
</div>
<div role="tabpanel" id="panel-chart" aria-labelledby="tab-chart" tabindex="0">
<svg viewBox="0 0 20 20" role="img" aria-label="Revenue by region, bar chart; North highest at 4.2 million">…</svg>
</div>
<div role="tabpanel" id="panel-table" aria-labelledby="tab-table" hidden>
<table>…</table>
</div>
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
function activate(tab) {
tabs.forEach(t => {
const on = t === tab;
t.setAttribute('aria-selected', String(on)); // SC 4.1.2
t.tabIndex = on ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
});
tab.focus();
saveView(tab.id); // remember the preference
}
tablist.addEventListener('keydown', (e) => {
const i = tabs.indexOf(document.activeElement);
const next = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
activate(tabs[(next + tabs.length) % tabs.length]); // automatic activation: views are cheap
});
tablist.addEventListener('click', (e) => { const t = e.target.closest('[role="tab"]'); if (t) activate(t); });
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Action | Expected announcement | AT deviations |
|---|---|---|---|
| Tab into tablist | Focus selected tab | “Revenue by region views, tab list, Chart, tab, selected, 1 of 2” | — |
| Right arrow | Next tab, activated | “Table, tab, selected, 2 of 2” | — |
| Home / End | First / last tab | Tab name | — |
| Tab | Into the active panel | Panel content or “Table, tab panel” | VoiceOver may announce the panel name |
| Shift+Tab from panel | Back to the selected tab | Tab name | — |
Integration context
Permalink to "Integration context"The table view is the accessible alternative for the chart, as described in show-as-table toggles for charts, which also covers a simpler disclosure variant. If panels load data, see lazy-loaded tab panels with data. Telling users about the switch is covered in announcing view changes in data apps.
Gotchas
Permalink to "Gotchas"Hidden panels keep state. If you unmount the table when the chart is shown, the user loses scroll position and sort. Prefer hidden over unmounting for light panels, or persist state.
Focusable panels. If a panel has no focusable content (a chart image), give it tabindex="0" so Tab can reach it; if it does, do not.
Tabs inside a grid toolbar. A tablist inside a toolbar inherits the toolbar’s arrow keys; keep tabs outside toolbars, or follow the toolbar’s model and drop the tab roles.
Framework notes
Permalink to "Framework notes"React Aria Tabs, Radix Tabs, Headless UI Tab and Angular Material mat-tab-group all implement the pattern. Check two settings: activation mode (Radix activationMode="manual", React Aria keyboardActivation="manual") and whether inactive panels are unmounted (Radix forceMount, Angular preserveContent).
<Tabs.Root defaultValue="chart" activationMode="automatic">
<Tabs.List aria-label="Revenue by region views">
<Tabs.Trigger value="chart">Chart</Tabs.Trigger><Tabs.Trigger value="table">Table</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="chart" forceMount hidden={view !== 'chart'}>…</Tabs.Content>
<Tabs.Content value="table" forceMount hidden={view !== 'table'}>…</Tabs.Content>
</Tabs.Root>
Worked example
Permalink to "Worked example"A revenue dashboard had “Chart” and “Table” pills implemented as two links that reloaded the page with ?view=table. Screen reader users heard “link, Table” with no sense of which view was current, and every switch reloaded the page and lost scroll. Rebuilding as a tablist with hidden (not unmounted) panels made the current view audible (“Table, tab, selected”), removed the reload, and kept the table’s sort when switching back. The team kept the URL parameter in sync with replaceState so shared links still open the right view.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide a ViewTabs component preconfigured for data views — automatic activation for cheap panels, manual for loaders, panels preserved by default — and a convention that every chart offers a Table tab.
FAQ
Permalink to "FAQ"Should "Table" and "Chart" be tabs or a toggle?
Tabs when the views share an area and users switch occasionally; a single “Show as table” toggle is fine too. Pick one model for the whole product.
Should the selected view persist across visits?
Yes. Users who rely on the table view should not have to switch every time. Store the preference per chart or globally.
Can tabs be links?
If each view has its own URL and switching is navigation, use links in a nav. If switching happens in place, use tabs. Do not mix the two semantics.
Does every panel need to be focusable?
Only if it has no focusable content; otherwise Tab already lands inside it.
How many tabs are too many?
More than five or six becomes hard to scan by ear. Consider a select or a menu for many views.
Should the table tab come first or second?
Many teams put the chart first because it is the visual default, which is fine. If your users are mostly analysing values, putting Table first is reasonable too. What matters is that the order is the same on every chart in the product.
Related
Permalink to "Related"- Show-as-table toggles — the simpler alternative
- Lazy-loaded tab panels — panels that fetch data
- Announcing view changes — feedback on switches