On this page
Tabs group related panels so users can switch between them without leaving the current context. An accessible implementation needs more than buttons styled as a row: it needs a consistent relationship between selection, keyboard focus, panel visibility, and the semantics exposed to assistive technology.
Confirm that tabs fit the task
Use tabs for related views within one context, not as a replacement for ordinary links to separate pages. If each item represents a destination that users should bookmark, open in a new tab, or share independently, navigation links may be the better foundation. Visual similarity does not determine semantics.
Use the contrast checker to compare tab text, selected-state markers, and focus rings against their backgrounds. Check focused and selected states separately: a user can focus one tab while another remains selected. Color testing supports the component but cannot validate its keyboard model.
The ARIA tabs pattern defines the expected relationships and interaction. Start from that contract instead of inventing shortcuts. The broader WCAG interface guide helps ensure the resulting widget also fits surrounding page requirements such as focus visibility and meaningful reading order.
Define selection and focus as separate states
The tablist contains tabs; each tab identifies a tabpanel. The selected tab has aria-selected set to true, while the others are false. Each panel uses aria-labelledby to reference its tab, and each tab uses aria-controls to identify its panel. All referenced IDs must be unique within the document.
Roving tabindex keeps the tablist to one stop in the normal Tab sequence. One tab has tabindex zero and the others have negative one. Arrow keys move focus within the group. Tab then leaves the tablist rather than forcing users through every tab as though it were a row of unrelated buttons.
Choose between manual activation and automatic activation. In the manual model, arrows move focus and Enter or Space selects the focused tab. In the automatic model, moving focus also selects it. Automatic activation is appropriate when panel display has no noticeable latency; slow network requests make arrow navigation frustrating.
The example below uses manual activation. Focus can therefore differ from selection until the user activates a tab. Home and End move to the first and last tabs. For a horizontal tablist, leave Up and Down alone so they can retain their usual page scrolling behavior.
Establish explicit markup relationships
This markup represents the enhanced state. The first panel is selected and visible; the second is hidden. The panels have tabindex zero because their content begins with nonfocusable text, giving keyboard users a useful landing point after leaving the tablist.
<div data-tabs>
<div role="tablist" aria-label="Account settings">
<button type="button" role="tab" id="profile-tab"
aria-selected="true" aria-controls="profile-panel"
tabindex="0">Profile</button>
<button type="button" role="tab" id="security-tab"
aria-selected="false" aria-controls="security-panel"
tabindex="-1">Security</button>
</div>
<section role="tabpanel" id="profile-panel"
aria-labelledby="profile-tab" tabindex="0">
<p>Update your public profile.</p>
</section>
<section role="tabpanel" id="security-panel"
aria-labelledby="security-tab" tabindex="0" hidden>
<p>Manage sign-in protection.</p>
</section>
</div>Keep tab labels concise and distinguishable. A screen reader should expose each tab's name and selection state without an extra aria-label overriding useful visible wording. Avoid interactive descendants inside a tab: nesting a close button within a button creates invalid markup and conflicting interaction expectations.
Use the native hidden attribute to remove inactive panels from presentation and ordinary keyboard access. Ensure your CSS does not override it with a blanket display rule. Merely translating a panel offscreen or setting opacity to zero can leave invisible controls in the Tab order.
Implement the manual keyboard model
Scope queries to one component instance so several tab groups can coexist. Updating focus and selection through small dedicated functions makes their separate responsibilities visible. Native button activation supplies click events for Enter and Space, so those keys do not need duplicate handlers here.
const root = document.querySelector('[data-tabs]');
const tabs = [...root.querySelectorAll('[role="tab"]')];
const panels = tabs.map(tab =>
document.getElementById(tab.getAttribute('aria-controls'))
);
function moveFocus(index) {
tabs.forEach((tab, i) => { tab.tabIndex = i === index ? 0 : -1; });
tabs[index].focus();
}
function select(index) {
tabs.forEach((tab, i) => {
tab.setAttribute('aria-selected', String(i === index));
panels[i].hidden = i !== index;
});
}
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => {
moveFocus(index);
select(index);
});
tab.addEventListener('keydown', event => {
let next;
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = tabs.length - 1;
if (next === undefined) return;
event.preventDefault();
moveFocus(next);
});
});This example assumes a horizontal, left-to-right tablist with valid markup. For vertical tabs, declare aria-orientation vertical and implement Up and Down instead. Establish and test a directional policy for right-to-left interfaces rather than silently assuming the same visual ordering everywhere.
If panels load asynchronously, preserve stable panel containers and expose loading or error information within the selected panel. Do not move focus to a spinner or back to the document body. Stable focus allows the user to decide whether to wait, switch tabs, or continue elsewhere.
Test enhancement, appearance, and maintenance
Provide a sensible progressive enhancement strategy. Before the script initializes, render all content as ordinary sections with headings or use a server-rendered working implementation. Add tab roles and hide inactive panels only when behavior is ready. Shipping the illustrated enhanced markup without working JavaScript would strand the hidden content.
Style selection and focus with different cues, such as a persistent selected underline and a separate focus outline. Consult MDN's focus-visible reference for browser-aware focus styling. Check zoom, long translated labels, and forced-colors mode rather than relying on background color alone.
Test Tab entry, arrow wrapping, Home, End, Enter, Space, and Tab exit. Confirm arrows do not select in the manual model and inactive panels contain no reachable controls. With a screen reader, verify tab names, selected state, and panel labels. Read the MDN tab role reference when investigating semantic mismatches.
Walk through a manual-activation test
Start with Profile selected and press Tab from the preceding control. Focus should land on Profile. Press Right Arrow: Security receives focus, but Profile remains selected and its panel stays visible. Press Space: Security becomes selected and its panel appears. Press Tab again: focus reaches the Security panel in this example, because the panel itself has tabindex zero. This sequence catches implementations that accidentally couple every focus change to selection.
Repeat with a pointer click on Profile, then return using the keyboard. Verify that only one tab remains in the page's Tab sequence. Also test Shift+Tab from the visible panel: it should return to the tablist's current roving stop, which may differ from the selected tab after arrow exploration in a manual model.
For nested widgets, narrow event handling to the owning tablist. The sample's descendant queries are suitable for a simple component, but nested tab groups would otherwise include inner tabs in the outer group's array. Initialize each root separately and filter out tabs belonging to another tablist before reusing this logic.
Finally, add regression cases to your development workflow for multiple instances, removed tabs, script failure, and delayed content. When removing the focused tab, deliberately choose a sensible remaining tab and update both focus and selection. Interaction tests should assert these transitions, not merely check that expected ARIA attributes exist.
Key takeaways
- Use tabs for related panels, not ordinary page navigation.
- Keep keyboard focus and selection distinct in manual activation.
- Connect tabs and panels through unique IDs and explicit relationships.
- Hide inactive panels reliably and preserve useful no-script content.
- Test complete keyboard sequences, screen reader output, and dynamic updates.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor