A tab list can look perfect and still hide a tab from the keyboard. In the Full Tabs Pattern lab on wasexamprep.com, one broken case hard-codes tabIndex={-1} on the second tab. A mouse can click it. Tab skips it. The arrow keys do nothing. That is a WCAG 2.1.1 Keyboard failure.
The other broken case puts every tab in the Tab order. That is clumsy and it is not the WAI-ARIA APG Tabs pattern, but every tab is still reachable and operable, so it passes 2.1.1. It is a best-practice deviation. The full write-up with the 2.1.1 reasoning, the tree evidence and the VoiceOver captures is on the blog. This post is about locking the behavior in with tests, and about the mistakes those tests catch.
One more thing makes this a good test case: the lab's fixed example changed this week. The panels got tabindex="0", and the handler stopped listening for ArrowUp and ArrowDown. Against the new component, the old test suite failed only once (the next Tab no longer left the widget, it landed on the panel). Nothing in it checked Up/Down, the panel's focus ring or Shift+Tab through the panel. The tests below fail on the old component and pass on the new one.
The contract
For a horizontal tab list with automatic activation:
- Tab into the tab list lands on the selected tab. It is the only tab with
tabindex="0"; every other tab has-1. - Left and Right Arrow move focus and selection to the previous and next tab, and wrap at the ends.
- Home and End go to the first and last tab.
- ArrowUp and ArrowDown are not handled, so they keep scrolling the page (APG: a horizontal tab list does not listen for them).
- The next Tab moves out of the tab list onto the visible panel, because the panel has no focusable content and has
tabindex="0". It needs a visible focus ring (2.4.7). - Shift+Tab walks back: panel, then the selected tab - not the first tab.
The lab's handler, from fixed-example.tsx:
function handleTabListKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const order = tabs.map((tab) => tab.id);
const index = order.indexOf(activeTab);
let nextIndex = index;
switch (event.key) {
case "ArrowRight":
nextIndex = (index + 1) % order.length;
break;
case "ArrowLeft":
nextIndex = (index - 1 + order.length) % order.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = order.length - 1;
break;
default:
return;
}
event.preventDefault();
selectTab(order[nextIndex]);
}
Each tab gets tabIndex={activeTab === tab.id ? 0 : -1} and aria-selected={activeTab === tab.id}, and each panel gets role="tabpanel", tabIndex={0}, aria-labelledby and hidden={activeTab !== tab.id}. selectTab sets the state and calls focus() on the new tab.
Testing Library + user-event
user.tab() follows the real Tab order, including tabindex and hidden, so it catches a wrong roving index or a missing panel stop. Put a button before and after the widget so you can prove exactly where focus goes.
function setup() {
const user = userEvent.setup();
render(
<>
<button type="button">Before</button>
<FixedExample />
<button type="button">After</button>
</>,
);
const tablist = screen.getByRole("tablist", { name: "Settings" });
const tab = (name: string) => within(tablist).getByRole("tab", { name });
const panel = (name: string) => screen.getByRole("tabpanel", { name });
return { user, tab, panel };
}
function expectActive(tab: HTMLElement) {
expect(tab).toHaveFocus();
expect(tab).toHaveAttribute("aria-selected", "true");
expect(tab).toHaveAttribute("tabindex", "0");
}
it("has one Tab stop in the tablist, then the panel, then out", async () => {
const { user, tab, panel } = setup();
await user.tab(); // "Before"
await user.tab();
expectActive(tab("Account"));
expect(tab("Notifications")).toHaveAttribute("tabindex", "-1");
expect(tab("Security")).toHaveAttribute("tabindex", "-1");
await user.tab(); // into the panel (no focusable content, so tabindex="0")
expect(panel("Account")).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "After" })).toHaveFocus();
});
it("ArrowUp and ArrowDown are not handled on a horizontal tablist", async () => {
const { user, tab } = setup();
await user.click(tab("Notifications"));
const down = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true });
tab("Notifications").dispatchEvent(down);
expect(down.defaultPrevented).toBe(false); // the page can still scroll
await user.keyboard("{ArrowDown}{ArrowUp}");
expectActive(tab("Notifications"));
});
it("Tab returns to the last selected tab, not the first one", async () => {
const { user, tab } = setup();
await user.click(tab("Account"));
await user.keyboard("{ArrowRight}");
await user.tab(); // panel
await user.tab(); // leave the widget
await user.tab({ shift: true }); // panel
await user.tab({ shift: true }); // tab
expectActive(tab("Notifications"));
});
The full file also covers Right with wrap, Left with wrap, Home/End, Shift+Tab from the panel and "all three panels stay in the DOM with tabindex="0", only one is exposed". The Up/Down test checks defaultPrevented directly: "focus did not move" alone would also pass for a handler that swallows the key and blocks scrolling.
The broken cases get tests too, written to the 2.1.1 line rather than to the APG:
it("Case 2: Notifications is never a Tab stop (2.1.1 failure)", async () => {
const { user, tabIn } = setupBroken();
const account = tabIn("Alert preferences", "Account");
const notifications = tabIn("Alert preferences", "Notifications");
await user.click(notifications); // a mouse can select it
expect(notifications).toHaveAttribute("aria-selected", "true");
expect(notifications).toHaveAttribute("tabindex", "-1"); // still out of the Tab order
const visited: Element[] = [];
screen.getByRole("button", { name: "Before" }).focus();
for (let i = 0; i < 8; i++) {
await user.tab();
visited.push(document.activeElement as Element);
}
expect(visited).toContain(account);
expect(visited).not.toContain(notifications);
});
Its sibling asserts that in Case 1 Tab reaches Notifications and Enter selects it: no arrow keys, but operable.
Run on 2026-11-01 with vitest 2.1.9 and jsdom, against FixedExample and BrokenExample copied from the lab: 10 of 10 pass. Against the pre-fix component, 4 fail: the panel stop, Shift+Tab, the panel tabindex check and Up/Down. Setup notes: toHaveFocus and toBeVisible come from @testing-library/jest-dom (import @testing-library/jest-dom/vitest in a setup file), and you need afterEach(cleanup) or the renders pile up.
Playwright, against a real browser
jsdom has no layout, no :focus-visible and no CSS, so the focus ring can only be checked in a browser. The helper below answers one question: which tabs can a keyboard-only user focus? It tries every arrow, Home, End and plain Tab, and only counts tabs inside the tab list under test.
async function keyboardReachableTabs(page: Page, tablist: Locator, firstTab: Locator) {
const reached = new Set<string>();
await firstTab.focus();
const record = async () => {
// Only count tabs inside THIS tablist (Tab can wander into the next widget).
const name = await tablist.evaluate((list) => {
const el = document.activeElement;
return el && list.contains(el) && el.getAttribute("role") === "tab" ? el.textContent?.trim() ?? "" : "";
});
if (name) reached.add(name);
};
await record();
const count = await tablist.getByRole("tab").count();
for (const key of ["ArrowRight", "ArrowLeft", "Home", "End"]) {
for (let i = 0; i < count; i++) {
await page.keyboard.press(key);
await record();
}
}
await firstTab.focus();
for (let i = 0; i < count; i++) {
await page.keyboard.press("Tab");
await record();
}
return [...reached].sort();
}
The fixed-flow test ends with the panel:
// One Tab press leaves the tablist and lands on the visible panel (tabindex="0").
await page.keyboard.press("Tab");
const panel = page.getByRole("tabpanel", { name: "Security" });
await expect(panel).toBeFocused();
await expect(panel).toHaveAttribute("tabindex", "0");
// The ring is a box-shadow (Tailwind focus-visible:ring-2), not an outline.
expect(await panel.evaluate((el) => el.matches(":focus-visible"))).toBe(true);
expect(await panel.evaluate((el) => getComputedStyle(el).boxShadow)).toMatch(/0px 0px 0px 2px/);
// The next Tab leaves the widget; Shift+Tab walks back panel > selected tab.
await page.keyboard.press("Tab");
await expect(panel).not.toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(panel).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(tab("Security")).toBeFocused();
And Case 2 is documented as a known bug, so the build stays green while the bug is visible:
test("broken Case 2: Notifications is unreachable (2.1.1)", async ({ page }) => {
test.fail(); // documents the bug: this assertion is expected to fail
const tablist = page.getByRole("tablist", { name: "Alert preferences", exact: true });
const reached = await keyboardReachableTabs(page, tablist, tablist.getByRole("tab", { name: "Account" }));
expect(reached).toEqual(["Account", "Notifications"]);
});
Run against production on 2026-11-01 (Playwright 1.64.1, headless Chromium): 5 of 5 as expected. The reachability test maps to 2.1.1; the fixed-flow test checks the APG convention. Keep them separate so a failing build tells you which one broke.
Common mistakes
-
Static
tabindex.0on the first tab,-1on the rest, never updated. After a click, the unselected first tab keeps the Tab stop, and with no arrow handler the other tabs are mouse-only. DerivetabIndexfrom the same state asaria-selected. -
Two sources of truth.
aria-selectedfrom one variable,tabIndexor the active style from another. OneactiveTab, everything derived from it. -
State without focus. Only calling
setActiveTabgives the new tabtabindex="0"while focus stays on the old tab, which now has-1. Callfocus(). -
No wrap.
Math.min(index + 1, last)stops at the end. Use(index + 1) % lengthand(index - 1 + length) % length. -
preventDefaulton every key. Withoutdefault: return, you swallow Tab and trap the user in the widget. -
Handling Up/Down on a horizontal tab list. It takes the page's scroll keys away. Leave them alone, or make the list vertical with
aria-orientation="vertical"and use Up/Down instead. Not a 2.1.1 failure, but not the pattern. -
A text-only panel with no Tab stop. Without
tabindex="0", Tab jumps from the tab straight past the content. And if you add the stop, add a visible focus ring with it. -
Manual activation with the automatic handler.
order.indexOf(activeTab)is correct only when focus and selection always match. With manual activation (arrows move focus, Enter or Space selects), compute the index fromdocument.activeElementand move focus only. -
Trusting
focusable: true. Chrome's accessibility tree reports it for atabindex="-1"tab. Focusable from script is not reachable with Tab. Press the keys. -
Substring names in Playwright.
getByRole("tablist", { name: "Settings" })also matched "Account settings" in the lab. Useexact: true. Testing Library's stringnameis exact by default. - Calling Case 1 a 2.1.1 failure. Tab on every tab is reachable and operable. Report it as a best-practice deviation; save 2.1.1 for the tab nobody can reach.
VoiceOver, from real captures only
From the lab's VoiceOver capture docs (VoiceOver on macOS, captured 2026-06-12, before the panel fix; the tabs did not change). Broken Case 2, the only tab in the Tab order:
Account, selected, tab, 1 of 2, Alert preferences, tab group
Fixed, Tab in, then Right Arrow twice:
Account, selected, tab, 1 of 3, Settings, tab group
Notifications, selected, tab, 2 of 3
Security, selected, tab, 3 of 3
The wrap, Left Arrow, Home, End and the Tab into the panel were captured on 2026-11-01 on the live lab after the panel fix, with VoiceOver on Mac (macOS 26.6.2, VoiceOver 10, Chrome 156.0.8078.79). These are keyboard-focus announcements, not VoiceOver-cursor reading:
- Right Arrow from Security (wraps): "Account selected tab, 1 of 3"
- Left Arrow from Account (wraps): "Security selected tab, 3 of 3"
- Home: "Account selected tab, 1 of 3"
- End: "Security selected tab, 3 of 3"
- Tab from the Notifications tab into its panel: "✅ Fixed Example region Notifications tab panel"
Keep practicing
Try both broken cases yourself in the Full Tabs Pattern lab on wasexamprep.com, then read the full write-up.
Watch the full walkthrough and subscribe to the A11Y in Practice YouTube channel for more hands-on accessibility episodes.
A11Y in Practice and wasexamprep.com are independent educational resources. They are not affiliated with or endorsed by IAAP (International Association of Accessibility Professionals).
Top comments (0)