Tabs show one panel at a time. They are a good test of accessible JavaScript, because the keyboard behaviour is specific: arrow keys move between tabs.
<style>
.tabs { font-family: system-ui, sans-serif; max-width: 360px; }
[role="tablist"] { display: flex; border-bottom: 2px solid #1a5276; }
[role="tab"] { font: inherit; padding: 8px 14px; border: 0; background: none; cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px; }
[role="tab"][aria-selected="true"] { border-bottom-color: #d94a00; font-weight: 700; }
[role="tab"]:focus-visible { outline: 3px solid #d94a00; outline-offset: -3px; }
[role="tabpanel"] { padding: 12px 4px; }
</style>
<div class="tabs" id="tabs">
<div role="tablist" aria-label="Course topics">
<button role="tab" id="t1" aria-controls="p1" aria-selected="true" tabindex="0">HTML</button>
<button role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">CSS</button>
<button role="tab" id="t3" aria-controls="p3" aria-selected="false" tabindex="-1">JavaScript</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">HTML gives a page its structure.</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>CSS controls how it looks.</div>
<div role="tabpanel" id="p3" aria-labelledby="t3" tabindex="0" hidden>JavaScript makes it respond.</div>
</div>
<script>
const tablist = document.querySelector("[role='tablist']");
const tabs = [...tablist.querySelectorAll("[role='tab']")];
function select(tab, setFocus = true) {
for (const t of tabs) {
const active = t === tab;
t.setAttribute("aria-selected", String(active));
t.tabIndex = active ? 0 : -1;
document.getElementById(t.getAttribute("aria-controls")).hidden = !active;
}
if (setFocus) tab.focus();
}
tablist.addEventListener("click", event => {
const tab = event.target.closest("[role='tab']");
if (tab) select(tab);
});
tablist.addEventListener("keydown", event => {
const index = tabs.indexOf(document.activeElement);
if (index === -1) return;
let next;
if (event.key === "ArrowRight") next = tabs[(index + 1) % tabs.length];
else if (event.key === "ArrowLeft") next = tabs[(index - 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(next);
});
</script>
What makes it accessible
- Roles:
tablist,tab,tabpanel. A screen reader announces “HTML, tab, 1 of 3, selected”. aria-selectedmarks the active tab,aria-controlsandaria-labelledbyconnect tabs and panels.- Roving tabindex: only the selected tab is in the Tab order. Arrow keys, Home and End move between tabs (Keyboard Interaction).
- Inactive panels use
hidden, so they are not reachable. - The tabs are real
<button>s, so Enter and Space work. - A visible focus style.
How it is organised
select(tab)is the one function that changes state. Clicks and arrow keys both call it.- Event delegation: one
clicklistener on the tab list.
Try changing it
- Make the selected tab follow the URL hash (
#css). - Remember the selected tab in
localStorage. - Add a fourth tab without touching the JavaScript: it should just work (it does, because the code reads the tabs from the page).
Native alternatives
<details>) are often better than tabs. Use tabs when users genuinely switch between related views.