Stockio

Accessible Tabs: Build a Keyboard-Friendly Tabs Component

Implement accessible tabs with correct roles, roving tabindex, manual activation, and panel relationships, then test keyboard behavior and progressive enhancement.

By Theo Okafor

UX Researcher ·

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.
Featured sponsor

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

Keep reading

Related articles

More in Accessibility
Accessibility

10 Best Accessibility Courses and Learning Resources

Learn practical web accessibility through standards, tutorials, videos, and real interface examples. These ten trusted resources help beginners and working teams build skills and choose a useful next step.

Includes: Contrast checkerNoor HaddadNoor Haddad
Accessibility

10 Best Accessibility Browser Extensions for Testing

Compare ten browser add-ons for spotting accessibility issues while you browse or build, from automated page scans to heading and contrast checks, plus advice for turning findings into fixes.

Includes: Contrast checkerTheo OkaforTheo Okafor
Accessibility

12 Best Accessibility Testing Tools for Websites

Compare twelve practical accessibility testing tools, from browser extensions and automated checks to screen readers and code libraries, and learn how to choose a sensible mix for your site.

Includes: Contrast checkerNoor HaddadNoor Haddad
Accessibility

Automated Accessibility Testing With axe and Lighthouse

Combine axe state-based checks with Lighthouse page audits, triage actionable findings, and add manual verification without mistaking a clean report for WCAG conformance.

Includes: Contrast checkerNoor HaddadNoor Haddad
Accessibility

How to Do an Accessibility Audit and Write the Report

Scope an accessibility audit, combine automated and manual testing, and write evidence-based findings that developers can reproduce, prioritize, fix, and retest.

Includes: Contrast checkerTheo OkaforTheo Okafor
Accessibility

Focus Indicators That Meet WCAG 2.2: A Practical Guide

Create reliable keyboard focus indicators, distinguish WCAG 2.2 AA requirements from AAA appearance targets, and test contrast, clipping, and sticky overlays.

Includes: Contrast checkerNoor HaddadNoor Haddad