Components
Tabs
One panel visible at a time, switched without leaving the page. The keyboard contract is the whole component — get it wrong and a ten-tab interface costs a keyboard user ten presses to walk past.
When to use it
For alternative views of one thing, where only one is useful at a time and the user is expected to switch between them freely.
When not to
- Not for sequential steps. Tabs imply the panels are peers and can be visited in any order. A wizard is not that; it has progress and a next step.
- Not for content people need to compare. Anything requiring the user to remember panel A while reading panel B should be on screen together.
- Not for primary navigation. Tabs do not change the URL, so a tabbed site cannot be linked into or reached with the back button.
- Not with more than about six. A scrolling tab strip hides its own options.
Try it with the keyboard
Tab into the list — it takes one press to reach it and one more to leave. Then use the arrow keys.
Invoice 2026-0114 · CHF 4,200.00 · due 30 days. Sent to accounts on 14 January.
Three lines. Design system audit, component build, documentation handover.
Created 12 Jan · sent 14 Jan · viewed 14 Jan · no payment recorded.
Keyboard
| Key | Result |
|---|---|
| Tab | Moves to the selected tab, then out to its panel. Never to the other tabs. |
| → / ← | Moves between tabs and activates as it goes, wrapping at both ends. |
| Home / End | Jumps to the first or last tab. With six tabs, arrowing to the last is five presses; this is one. |
Activation follows focus
Arrowing to a tab selects it immediately, rather than requiring Enter. That is the right trade when panels are already in the DOM and switching is free — it matches what sighted mouse users get. If a panel were expensive to load, manual activation would be correct instead, and the component would need a separate mode. Halbton's panels are cheap, so it does not have one.
Markup
<div class="ht-tabs" data-ht-tabs>
<div class="ht-tabs__list" role="tablist" aria-label="Invoice details">
<button class="ht-tabs__tab" role="tab" id="t-1"
aria-controls="p-1" aria-selected="true" tabindex="0">Summary</button>
<button class="ht-tabs__tab" role="tab" id="t-2"
aria-controls="p-2" aria-selected="false" tabindex="-1">Lines</button>
</div>
<div class="ht-tabs__panel" role="tabpanel" id="p-1"
aria-labelledby="t-1" tabindex="0">…</div>
<div class="ht-tabs__panel" role="tabpanel" id="p-2"
aria-labelledby="t-2" tabindex="0" hidden>…</div>
</div> Accessibility
- The list is role="tablist" with an aria-label saying what the tabs are for — "Invoice details", not "Tabs".
- Each panel is labelled by its tab, so its accessible name is the tab's text without repeating it.
- Panels are tabindex="0", so a keyboard user can move focus into panel content that has no focusable children of its own.
- The selected tab is marked by weight and an underline as well as colour.
- Focus is never moved on setup — only in response to a key or a click. Stealing focus on load would yank a reader out of position.
Source
Framework-free, and the demo above runs this exact module. What you are reading is what just handled your arrow keys.
/**
* Tabs, with a roving tabindex.
*
* Framework-free, like the modal, and documented by rendering this file's own
* source. The Tabs page runs exactly this module.
*
* The contract that matters, and the one most implementations get wrong: a tab
* list is **one** stop in the page's tab order, not one per tab. Tab moves into
* the active tab and then out to the panel; the arrow keys move between tabs.
* Making every tab focusable with Tab means a ten-tab interface costs a
* keyboard user ten presses to walk past.
*
* Markup contract:
*
* <div class="ht-tabs" data-ht-tabs>
* <div class="ht-tabs__list" role="tablist" aria-label="…">
* <button role="tab" id="t1" aria-controls="p1" aria-selected="true" tabindex="0">One</button>
* <button role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">Two</button>
* </div>
* <div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
* <div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>
* </div>
*/
/** @param {HTMLElement} root @param {HTMLElement} tab @param {boolean} focus */
function select(root, tab, focus) {
const tabs = [...root.querySelectorAll('[role="tab"]')];
for (const t of tabs) {
const selected = t === tab;
t.setAttribute("aria-selected", String(selected));
/* The roving part: only the selected tab is reachable with Tab. */
t.tabIndex = selected ? 0 : -1;
const panel = document.getElementById(t.getAttribute("aria-controls"));
if (panel) panel.hidden = !selected;
}
/* Only move focus for keyboard and click activation, never on initial
setup — stealing focus on page load would yank a reader out of place. */
if (focus) tab.focus();
}
/** @param {HTMLElement} root */
export function initTabs(root) {
const tabs = [...root.querySelectorAll('[role="tab"]')];
if (!tabs.length) return;
const active = tabs.find((t) => t.getAttribute("aria-selected") === "true") ?? tabs[0];
select(root, active, false);
root.addEventListener("click", (event) => {
const tab = event.target.closest('[role="tab"]');
if (tab && root.contains(tab)) select(root, tab, true);
});
root.addEventListener("keydown", (event) => {
const current = event.target.closest('[role="tab"]');
if (!current) return;
const i = tabs.indexOf(current);
let next = null;
/* Home and End matter more than they look: with ten tabs, arrowing to the
last one is nine presses. */
if (event.key === "ArrowRight") next = tabs[(i + 1) % tabs.length];
else if (event.key === "ArrowLeft") next = tabs[(i - 1 + tabs.length) % tabs.length];
else if (event.key === "Home") next = tabs[0];
else if (event.key === "End") next = tabs[tabs.length - 1];
else return;
event.preventDefault();
select(root, next, true);
});
}
/** Wire every tab set on the page. */
export function init() {
document.querySelectorAll("[data-ht-tabs]").forEach(initTabs);
}