Skip to content

Tabs

3 min read · Updated 8 Oct 2026

Tabs switch between views that share one page and its filters, such as Overview, Details and Records. Header tabs sit in a bar below the header; in-panel tabs sit in a card’s title row. Only the selected view is shown and mounted.

Tabs: One selected tab shows its view while sibling panels stay hidden.

Illustrative example with synthetic data. One selected tab shows its view while sibling panels stay hidden.

The page needs Use
Separate pages with their own subjects Side rail or a hub
One chart shown with a different measure Metric switcher
One item opened from an overview Drill-through
Everything visible at once A layout with more rows, such as KPI-led
  1. Do the tabs change what one card shows, while the rest of the page stays? Use in-panel tabs. Otherwise, use header tabs.
  2. Should each view have its own address, for Back, bookmarks and shared links? Use links. Otherwise, use ARIA tabs.
Variant Where Common use cases Choose another variant when
Header tabs A bar below the header; each tab shows a board Sales: Overview, Products, Orders. Support: Summary, Agents, Tickets The views are separate subjects; use a side rail
In-panel tabs A card’s title row; each tab shows one object A trend as a chart or as a table. Sales by region or by channel Only the measure changes; use a metric switcher, which keeps one object

Use two to five tabs, with labels of one or two words.

<div class="tabs" role="tablist" aria-label="Views">
<button class="tab" type="button" role="tab" id="tab-overview" aria-selected="true" aria-controls="panel-overview">
Overview
</button>
<button
class="tab"
type="button"
role="tab"
id="tab-records"
aria-selected="false"
aria-controls="panel-records"
tabindex="-1"
>
Records
</button>
</div>
<section class="tab-panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
<!-- a board -->
</section>
<section class="tab-panel" role="tabpanel" id="panel-records" aria-labelledby="tab-records" hidden>
<!-- a board -->
</section>

When each view has an address, the tabs are navigation: use links with aria-current="page", and no role="tab". Use hash addresses, so switching stays in one document and keeps its selections.

<nav class="tabs" aria-label="Views">
<a class="tab" href="#/overview" aria-current="page">Overview</a>
<a class="tab" href="#/records">Records</a>
</nav>
:root {
--tabs-h: 44px;
}
.tabs {
display: flex;
gap: 4px;
/* The border is inside --tabs-h, so the bar is exactly the height the board subtracts. */
box-sizing: border-box;
height: var(--tabs-h);
overflow-x: auto;
border-bottom: 1px solid var(--line);
}
.tab {
display: inline-flex;
flex: none;
align-items: center;
padding: 0 12px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--muted);
font: inherit;
text-decoration: none;
}
.tab:is([aria-selected='true'], [aria-current='page']) {
border-bottom-color: var(--accent);
color: var(--ink);
}

Header tabs add var(--tabs-h) to --above-board; set the sum once, as Bands above the board describes. Add 44px to each height threshold of the panel’s layout: the 728px example in Calculate a scrolling threshold becomes 772px.

.tab-card .tabs {
--tabs-h: 32px;
border-bottom: 0;
}

Put the tab list in the card’s title row, so it fits the 38px of title and subtitle; see Card. Each panel is an object host in the card’s host cell.

An author display rule on a panel overrides hidden; write layout rules as .tab-panel:not([hidden]).

Key Action
Tab Moves into the tab list, to the selected tab, then out
Left and Right arrows Move focus to the previous or next tab, wrapping
Home and End Move focus to the first or last tab
Enter or Space Selects the focused tab

Use manual activation, so arrowing across tabs does not mount every panel. Links use the browser’s own keyboard handling.

A hidden panel’s objects keep querying; see Hidden means unmounted. Use that page’s Panel class, one per object. Show the panel before mounting its objects, so each host has a box.

type Lifecycle = { show(host: HTMLElement): void; hide(): void };
type View = { panel: HTMLElement; objects: { panel: Lifecycle; host: HTMLElement }[] };
const tablist = document.querySelector<HTMLElement>('[role="tablist"]');
if (!tablist) throw new Error('Tab list not found.');
const tabs = [...tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
const views = new Map<HTMLButtonElement, View>(); // filled when the page builds its objects
const select = (selected: HTMLButtonElement) => {
for (const tab of tabs) {
const view = views.get(tab);
if (tab === selected || !view) continue;
tab.setAttribute('aria-selected', 'false');
tab.tabIndex = -1;
for (const { panel } of view.objects) panel.hide();
view.panel.hidden = true;
}
const view = views.get(selected);
if (!view) return;
selected.setAttribute('aria-selected', 'true');
selected.tabIndex = 0;
view.panel.hidden = false;
for (const { panel, host } of view.objects) panel.show(host);
};
tablist.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const tab = event.target.closest<HTMLButtonElement>('[role="tab"]');
if (tab && tab.getAttribute('aria-selected') !== 'true') select(tab);
});
// After filling `views`: mount the view the markup starts on.
const initialTab = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true');
if (initialTab) select(initialTab);
tablist.addEventListener('keydown', (event) => {
const index = tabs.findIndex((tab) => tab === document.activeElement);
if (index < 0) return;
const target =
event.key === 'ArrowRight'
? tabs[(index + 1) % tabs.length]
: event.key === 'ArrowLeft'
? tabs[(index - 1 + tabs.length) % tabs.length]
: event.key === 'Home'
? tabs[0]
: event.key === 'End'
? tabs[tabs.length - 1]
: undefined;
if (!target) return;
event.preventDefault();
target.focus();
});

Enter and Space on a focused <button> fire click, so the click listener also handles the keyboard.

Selecting a tab costs one query per object in its panel, with the applied selections current at that moment. All panels share the page’s selection group.

Width Header tabs In-panel tabs
Any Fixed height; tabs that do not fit scroll sideways In the title row; the title truncates first