Tabs
The WAI-ARIA tab pattern, with arrow-key navigation and a sliding indicator.
<div class="tabs">
<div role="tablist" aria-label="Deployment output">
<button role="tab" id="t-build" aria-controls="p-build" aria-selected="true" tabindex="0">Build</button>
<button role="tab" id="t-deploy" aria-controls="p-deploy" aria-selected="false" tabindex="-1">Deploy</button>
<button role="tab" id="t-logs" aria-controls="p-logs" aria-selected="false" tabindex="-1">Logs</button>
<button role="tab" id="t-config" aria-controls="p-config" aria-selected="false" tabindex="-1">Config</button>
</div>
<div role="tabpanel" id="p-build" aria-labelledby="t-build" tabindex="0">
<h3>Build</h3>
<p>Completed in 12.4 seconds. 48 files written to <code>dist/</code>, 1.2 MB before compression.</p>
</div>
<div role="tabpanel" id="p-deploy" aria-labelledby="t-deploy" tabindex="0" hidden>
<h3>Deploy</h3>
<p>Pushed to 14 edge locations. Propagation finished 38 seconds after the build.</p>
</div>
<div role="tabpanel" id="p-logs" aria-labelledby="t-logs" tabindex="0" hidden>
<h3>Logs</h3>
<p>No warnings. Two notices about unused CSS in <code>vendor.css</code>.</p>
</div>
<div role="tabpanel" id="p-config" aria-labelledby="t-config" tabindex="0" hidden>
<h3>Config</h3>
<p>Read from <code>acme.config.js</code>. Node 22, output directory <code>dist</code>.</p>
</div>
</div>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 24px; background: #fff; }
.tabs { max-width: 620px; margin: 0 auto; }
[role="tablist"] {
display: flex;
gap: 2px;
border-bottom: 1px solid #e2e7ef;
overflow-x: auto;
scrollbar-width: none;
}
[role="tablist"]::-webkit-scrollbar { display: none; }
[role="tab"] {
position: relative;
font: inherit;
font-size: .92rem;
font-weight: 500;
padding: 11px 16px;
border: 0;
background: none;
color: #8593ab;
cursor: pointer;
white-space: nowrap;
}
[role="tab"]:hover { color: #16202e; }
[role="tab"]:focus-visible { outline: 2px solid #3b4fe4; outline-offset: -2px; border-radius: 6px 6px 0 0; }
[role="tab"][aria-selected="true"] { color: #2a3abf; }
/* the underline sits on the tab itself and overlaps the list's border */
[role="tab"]::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 2px;
background: #3b4fe4;
transform: scaleX(0);
transition: transform .18s ease;
}
[role="tab"][aria-selected="true"]::after { transform: scaleX(1); }
[role="tabpanel"] {
padding: 22px 2px;
color: #5b6b83;
}
[role="tabpanel"]:focus-visible { outline: 2px solid #3b4fe4; outline-offset: 4px; border-radius: 6px; }
[role="tabpanel"] h3 { font-size: 1.05rem; color: #16202e; margin-bottom: 7px; }
code { background: #eef1f6; padding: 1px 5px; border-radius: 4px; font-size: .9em; }
@media (prefers-reduced-motion: reduce) {
[role="tab"]::after { transition: none; }
}const list = document.querySelector('[role="tablist"]');
const tabs = [...list.querySelectorAll('[role="tab"]')];
function select(tab, focus = true) {
tabs.forEach((t) => {
const on = t === tab;
t.setAttribute("aria-selected", on ? "true" : "false");
// roving tabindex: only the selected tab is in the tab order
t.setAttribute("tabindex", on ? "0" : "-1");
document.getElementById(t.getAttribute("aria-controls")).hidden = !on;
});
if (focus) tab.focus();
}
list.addEventListener("click", (e) => {
const tab = e.target.closest('[role="tab"]');
if (tab) select(tab);
});
list.addEventListener("keydown", (e) => {
const i = tabs.indexOf(document.activeElement);
if (i === -1) return;
let next = null;
if (e.key === "ArrowRight") next = tabs[(i + 1) % tabs.length];
else if (e.key === "ArrowLeft") next = tabs[(i - 1 + tabs.length) % tabs.length];
else if (e.key === "Home") next = tabs[0];
else if (e.key === "End") next = tabs[tabs.length - 1];
if (next) { e.preventDefault(); select(next); }
});How it works
This follows the WAI-ARIA authoring practices for tabs. The pattern is specific, and a tab component that does not follow it is one of the more confusing things a screen reader user can meet.
Roving tabindex. Only the selected tab has tabindex="0"; the others are -1. So Tab moves into the tab list and then straight out to the panel — it does not walk through every tab. Arrow keys move between tabs. That is the contract users expect from a tablist, and it is the part most implementations miss.
Home and End. Part of the same contract. Three extra lines.
Selecting on arrow, not on Enter. This is "automatic activation", and it is the right choice when panels are already in the DOM and switching is instant. If a panel loads data over the network, use manual activation instead — move focus on arrow, switch only on Enter or Space — so arrowing through does not fire four requests.
The panels are tabbable. tabindex="0" on each [role="tabpanel"] means Tab from the selected tab lands on the panel content. Without it, a panel whose content has no focusable elements is unreachable by keyboard.
hidden, not display: none in CSS. The hidden attribute removes the panel from the accessibility tree and keeps the markup honest about what is showing. Toggling a class leaves the state in two places.
The underline overlaps the list border. bottom: -1px puts the 2px indicator over the 1px line rather than below it, which is what makes the active tab look joined to its panel.
Accessibility notes
The tablist has an aria-label. With more than one set of tabs on a page, unlabelled tablists are indistinguishable.
Every tab points at its panel with aria-controls, and every panel points back with aria-labelledby. The pairing is what lets assistive technology say "Build, tab 1 of 4, selected".
Focus moves to the newly selected tab on arrow key, and the focus ring is inset so it is not clipped by the list's overflow.
If you switch to manual activation, add aria-selected changes only on activation — moving focus without changing selection is exactly what manual activation means.
Making it yours
Tabs are not always the right answer. They hide content from search engines' snippets and from browser find-in-page, and they force a choice before the reader knows what is in each panel. For content people may want to read in sequence, an accordion or plain headings usually serve better.
For deep-linkable tabs, read location.hash on load and call select() for the matching tab, and update the hash on change. Use history.replaceState rather than setting location.hash directly, which would scroll the page.
The tab list scrolls horizontally when it overflows rather than wrapping, with the scrollbar hidden. On a narrow screen with six tabs that is the right behaviour — wrapping to two rows makes the indicator line meaningless.
Related templates
Button set
Primary, secondary, ghost and danger, in three sizes, with every state covered.
HTMLCSSTailwindLoaders and skeletons
Six loading indicators in pure CSS, plus the skeleton pattern that usually beats all of them.
HTMLCSSModal dialog
A modal built on the native dialog element — focus trap and backdrop included, for free.
HTMLCSSJavaScriptAccordion and FAQ
Built on details and summary — open, close and keyboard support with no JavaScript.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.