An accessible interface starts with native elements: <button>, <a>, <label> and <dialog> already handle focus, keyboard and the right roles. ARIA is for cases with no native element — tabs, for example — and then you must implement the keyboard behaviour yourself following the WAI-ARIA APG pattern. Testing is automated (axe inside Playwright tests) plus manual: axe finds only part of the problems, while keyboard traps and lost focus are caught by keyboard tests and by a person.
After this you will be able to
- Walk a page by keyboard and find elements that Tab doesn’t reach or where focus isn’t visible
- Implement a disclosure menu, tabs with a roving tabindex and a modal
<dialog>with focus return, following the WAI-ARIA APG - Choose between a native element,
aria-label,aria-labelledby,aria-describedbyand a live region for a given task - Write Playwright tests for focus and keyboard and run axe via
@axe-core/playwrightin several UI states
Know beforehand
Verified with
HTML Living Standard (dialog, showModal)WAI-ARIA APG (dialog, tabs, disclosure)WCAG 2.2Playwright 1.64 (Chromium 156)@axe-core/playwright 4.13 (axe-core 4.13)Vite 8.3Node.js 24 LTS (24.18)
Checked against docs: October 11, 2026
Contents
The first rule of ARIA: a native element is better
What goes into the mockup — contrast, target size, focus order and appearance — is covered in our accessibility lesson for designers. This is the other half of the job: what a developer does so the interface can be used without a mouse and with a screen reader (software that reads the page aloud).
ARIA (Accessible Rich Internet Applications) is a set of role and aria-* attributes that tell assistive technologies what an element is and what state it is in. W3C’s “Using ARIA” document sets out rules for using it. First: if a native HTML element with the semantics and behaviour you need exists, use it instead of repurposing another element with an ARIA role. Second: don’t change native semantics unless you really have to. Third: all interactive ARIA controls must be usable with the keyboard. Fourth: don’t put role="presentation" or aria-hidden="true" on a focusable element.
Here is a typical “before” version of a coffee shop page. It works with a mouse, but almost every element breaks one of the rules:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Coffee House — before</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0 1rem; }
*:focus { outline: none; }
.muted { color: #aaa; }
.tab { display: inline-block; padding: 4px 8px; cursor: pointer; }
</style>
</head>
<body>
<div class="header">
<div class="menu-toggle" onclick="document.querySelector('.menu').hidden = !document.querySelector('.menu').hidden">☰</div>
<ul class="menu" hidden>
<li><a href="#drinks">Drinks</a></li>
<li><a href="#delivery">Delivery</a></li>
</ul>
<button class="search"><svg width="16" height="16" viewBox="0 0 16 16"><circle cx="7" cy="7" r="5" fill="none" stroke="black" /></svg></button>
</div>
<h1>Coffee House</h1>
<img src="/latte.svg" />
<div class="tabs">
<div class="tab" onclick="">Coffee</div>
<div class="tab" onclick="">Tea</div>
</div>
<p class="muted">Open daily 8:00–22:00</p>
<span>Phone</span> <input type="tel" />
<div class="button" onclick="alert('booked')">Book a table</div>
</body>
</html><div onclick> for the menu, tabs and “Book a table”
- Why it’s bad
- Not in the Tab order, not activated by Enter and Space, not announced as a button
- Fix
<button type="button">
A <button> with only an SVG inside
- Why it’s bad
- The button has no accessible name — a screen reader just says “button”
- Fix
- Text inside the button or
aria-label="Search"
<span>Phone</span> <input>
- Why it’s bad
- The field isn’t associated with its label
- Fix
<label for="…">
<img> without alt
- Why it’s bad
- A screen reader reads the file name or skips the meaning
- Fix
altwith content, oralt=""for decoration
*:focus { outline: none; }
- Why it’s bad
- A keyboard user can’t see where they are
- Fix
- A custom style via
:focus-visible
Keyboard: Tab order, tabindex and visible focus
Tab order follows the order of elements in the DOM. So the first step is correct markup in the correct sequence, not reordering with CSS. The tabindex attribute is used with three kinds of values, and only two are useful:
tabindex="0"
- What it does
- Adds the element to the Tab order at its DOM position
- When to use
- A tab panel with no focusable content, a custom widget with an ARIA role
tabindex="-1"
- What it does
- Removes it from the Tab order but allows
element.focus()from code - When to use
- Inactive tabs (roving tabindex), a heading you move focus to
tabindex="1" or higher
- What it does
- Puts the element ahead of everything else
- When to use
- Practically never: the order stops matching what’s on screen
Visible focus is a WCAG 2.2 requirement (success criterion 2.4.7, level AA). Removing outline without a replacement is not allowed. The :focus-visible pseudo-class shows the ring when the browser decides the user needs it — during keyboard navigation — and usually hides it after a mouse click. The full CSS of the fixed page:
body { font-family: system-ui, sans-serif; color: #1f2328; background: #fff; margin: 0 1rem; }
button, input { font: inherit; }
:focus-visible { outline: 3px solid #1a56db; outline-offset: 2px; }
[role="tab"][aria-selected="true"] { border-bottom: 3px solid currentColor; }
dialog::backdrop { background: rgb(0 0 0 / 0.5); }
.error { color: #b42318; }
#site-menu a { display: inline-block; padding: 8px 4px; }APG patterns in code: menu, tabs, dialog
For composite widgets W3C maintains the WAI-ARIA Authoring Practices Guide (APG): which roles and attributes to use and which keys must work. We take three patterns. A navigation dropdown is a disclosure, not role="menu": the APG explains that the menu role promises the complex behaviour of an application menu, which ordinary site navigation doesn’t need. Tabs have no native equivalent, so ARIA is required. The modal is a native <dialog>.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Coffee House — accessible version</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header>
<nav aria-label="Main">
<button type="button" id="menu-button" aria-expanded="false" aria-controls="site-menu">
Menu
</button>
<ul id="site-menu" hidden>
<li><a href="#drinks">Drinks</a></li>
<li><a href="#delivery">Delivery</a></li>
<li><a href="#contacts">Contacts</a></li>
</ul>
</nav>
</header>
<main>
<h1>Coffee House</h1>
<section id="drinks">
<h2 id="drinks-heading">Drinks</h2>
<div role="tablist" aria-labelledby="drinks-heading">
<button type="button" role="tab" id="tab-coffee" aria-selected="true" aria-controls="panel-coffee">Coffee</button>
<button type="button" role="tab" id="tab-tea" aria-selected="false" aria-controls="panel-tea" tabindex="-1">Tea</button>
<button type="button" role="tab" id="tab-desserts" aria-selected="false" aria-controls="panel-desserts" tabindex="-1">Desserts</button>
</div>
<div role="tabpanel" id="panel-coffee" aria-labelledby="tab-coffee" tabindex="0">
<p>Espresso, flat white, latte.</p>
</div>
<div role="tabpanel" id="panel-tea" aria-labelledby="tab-tea" tabindex="0" hidden>
<p>Green, black, herbal.</p>
</div>
<div role="tabpanel" id="panel-desserts" aria-labelledby="tab-desserts" tabindex="0" hidden>
<p>Cheesecake, croissant.</p>
</div>
</section>
<section id="delivery">
<h2>Book a table</h2>
<button type="button" id="book-button">Book a table</button>
<p id="booking-status" role="status" aria-live="polite"></p>
</section>
</main>
<dialog id="book-dialog" aria-labelledby="book-title">
<h2 id="book-title">Book a table</h2>
<form id="book-form" novalidate>
<label for="book-name">Name</label>
<input id="book-name" name="name" autocomplete="name" />
<label for="book-phone">Phone</label>
<input
id="book-phone"
name="phone"
type="tel"
autocomplete="tel"
aria-describedby="book-phone-hint book-phone-error"
/>
<p id="book-phone-hint">We will only call to confirm the booking.</p>
<p id="book-phone-error" class="error"></p>
<button type="submit">Confirm</button>
<button type="button" id="book-cancel">Cancel</button>
</form>
</dialog>
<footer id="contacts"><p>Almaty, open daily 8:00–22:00</p></footer>
<script type="module" src="/app.js"></script>
</body>
</html>// 1. Disclosure: the site menu
const menuButton = document.querySelector("#menu-button");
const menu = document.querySelector("#site-menu");
function setMenuOpen(open) {
menuButton.setAttribute("aria-expanded", String(open));
menu.hidden = !open;
}
menuButton.addEventListener("click", () => {
setMenuOpen(menuButton.getAttribute("aria-expanded") !== "true");
});
menuButton.parentElement.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !menu.hidden) {
setMenuOpen(false);
menuButton.focus();
}
});
// 2. Tabs: roving tabindex, arrows, Home/End, automatic activation
const tabs = [...document.querySelectorAll('[role="tab"]')];
function selectTab(tab) {
for (const t of tabs) {
const selected = t === tab;
t.setAttribute("aria-selected", String(selected));
t.tabIndex = selected ? 0 : -1;
document.getElementById(t.getAttribute("aria-controls")).hidden = !selected;
}
tab.focus();
}
for (const tab of tabs) {
tab.addEventListener("click", () => selectTab(tab));
tab.addEventListener("keydown", (event) => {
const i = tabs.indexOf(tab);
const next = {
ArrowRight: tabs[(i + 1) % tabs.length],
ArrowLeft: tabs[(i - 1 + tabs.length) % tabs.length],
Home: tabs[0],
End: tabs[tabs.length - 1],
}[event.key];
if (next) {
event.preventDefault();
selectTab(next);
}
});
}
// 3. Modal dialog: native <dialog> + showModal()
const bookButton = document.querySelector("#book-button");
const dialog = document.querySelector("#book-dialog");
const form = document.querySelector("#book-form");
const phone = document.querySelector("#book-phone");
const phoneError = document.querySelector("#book-phone-error");
const bookingStatus = document.querySelector("#booking-status");
bookButton.addEventListener("click", () => dialog.showModal());
document.querySelector("#book-cancel").addEventListener("click", () => dialog.close());
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!phone.value.trim()) {
phone.setAttribute("aria-invalid", "true");
phoneError.textContent = "Enter a phone number.";
phone.focus();
return;
}
phone.removeAttribute("aria-invalid");
phoneError.textContent = "";
dialog.close();
bookingStatus.textContent = "Table booked. We will call you to confirm.";
});Disclosure (menu)
- Roles and attributes
<button>witharia-expandedandaria-controls; a list of links withhidden- Keyboard
- Enter and Space toggle; Escape closes and returns focus to the button (as in the APG example)
Tabs
- Roles and attributes
- A labelled
tablist,tabwitharia-selectedandaria-controls,tabpanelwitharia-labelledbyandtabindex="0" - Keyboard
- Tab enters on the selected tab and leaves to the panel; ← → wrap around; Home/End go to the first and last
Dialog (modal)
- Roles and attributes
<dialog>witharia-labelledbypointing at its heading;showModal()makes it modal- Keyboard
- Focus moves in on open, Tab doesn’t reach the page, Escape closes, focus returns to the button
- The tabs use a roving tabindex: the selected tab has
tabindex="0", the rest-1. The whole group is a single Tab stop, and arrow keys move between tabs. The APG recommends activating a tab on focus when its panel shows without delay, as it does here. - According to MDN,
showModal()makes everything outside the dialog inert, closes it on Escape and implicitly setsaria-modal="true". Per the HTML specification, on open focus goes to the element withautofocusor the first suitable element inside, and when a modal dialog closes it returns to the element that had focus before it opened. In Chromium 156 the test below confirmed this — without a single line of code to restore focus. - A hand-made modal
<div role="dialog">is possible, but then the focus trap, Escape, inert background and focus return all have to be written and tested by you — the APG pattern describes exactly what.
Names, descriptions and live regions
Every interactive element needs an accessible name — what a screen reader announces and what tests use to find it. Sources of a name, from most to least preferred: the element’s visible text or an associated <label>; aria-labelledby pointing at visible text (that’s how the dialog, tab list and panels are labelled); aria-label when there is no visible text — for example, on an icon button. A description is extra text read after the name: a format hint and a field error are linked with aria-describedby.
A live region announces changes on the page that focus didn’t move to. In the example, after a booking the text appears in <p role="status" aria-live="polite">. MDN advises two things: keep the live region empty in the initial markup and change only its content, and add a redundant aria-live="polite" to role="status" for compatibility. polite waits for a pause in the screen reader’s speech; assertive interrupts it, and MDN recommends it only for urgent messages.
Automated checks: Playwright and axe
axe-core is Deque’s automated accessibility testing engine, and @axe-core/playwright is its Playwright wrapper. You don’t need Lighthouse separately: its accessibility audit weights checks by axe user-impact assessments, and each audit links to an axe rule. The test project is a static page that Playwright serves with Vite before the tests run.
npm init -y
npm pkg set type=module
npm install -D @playwright/test @axe-core/playwright vite typescript @types/node
npx playwright install chromiumimport { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "tests",
use: { baseURL: "http://127.0.0.1:4173" },
webServer: {
command: "npx vite --host 127.0.0.1 --port 4173 --strictPort",
url: "http://127.0.0.1:4173",
reuseExistingServer: true,
},
});import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
const WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
async function expectNoAxeViolations(page: Page) {
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
expect(results.violations).toEqual([]);
}
let errors: string[] = [];
test.beforeEach(async ({ page }) => {
errors = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
});
test.afterEach(() => {
expect(errors).toEqual([]);
});
test("axe: no violations in closed, menu-open and dialog-open states", async ({ page }) => {
await expectNoAxeViolations(page);
await page.getByRole("button", { name: "Menu" }).click();
await expectNoAxeViolations(page);
await page.getByRole("button", { name: "Book a table" }).click();
await expectNoAxeViolations(page);
});
test("disclosure menu: Enter opens, Escape closes and returns focus", async ({ page }) => {
const toggle = page.getByRole("button", { name: "Menu" });
await page.keyboard.press("Tab");
await expect(toggle).toBeFocused();
await page.keyboard.press("Enter");
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: "Drinks" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("link", { name: "Drinks" })).toBeHidden();
await expect(toggle).toBeFocused();
});
test("tabs: one tab stop, arrows and Home/End move selection", async ({ page }) => {
const coffee = page.getByRole("tab", { name: "Coffee" });
const tea = page.getByRole("tab", { name: "Tea" });
const desserts = page.getByRole("tab", { name: "Desserts" });
await page.keyboard.press("Tab"); // Menu
await page.keyboard.press("Tab"); // the selected tab
await expect(coffee).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(tea).toBeFocused();
await expect(tea).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("tabpanel", { name: "Tea" })).toBeVisible();
await expect(page.getByRole("tabpanel", { name: "Coffee" })).toBeHidden();
await page.keyboard.press("End");
await expect(desserts).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(coffee).toBeFocused();
await page.keyboard.press("Tab"); // leaves the tablist for the panel
await expect(page.getByRole("tabpanel", { name: "Coffee" })).toBeFocused();
});
test("dialog: focus moves in, stays in, Escape returns it", async ({ page }) => {
const open = page.getByRole("button", { name: "Book a table" });
const dialog = page.getByRole("dialog", { name: "Book a table" });
await open.focus();
await page.keyboard.press("Enter");
await expect(dialog).toBeVisible();
await expect(page.getByLabel("Name")).toBeFocused();
for (let i = 0; i < 6; i++) {
await page.keyboard.press("Tab");
const outside = await page.evaluate(() => {
const active = document.activeElement;
const dlg = document.querySelector("dialog");
return active !== document.body && !dlg?.contains(active);
});
expect(outside).toBe(false);
}
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(open).toBeFocused();
});
test("dialog form: error is announced, success goes to the live region", async ({ page }) => {
await page.getByRole("button", { name: "Book a table" }).click();
const phone = page.getByLabel("Phone");
await page.getByRole("button", { name: "Confirm" }).click();
await expect(phone).toBeFocused();
await expect(phone).toHaveAttribute("aria-invalid", "true");
await expect(phone).toHaveAccessibleDescription(/Enter a phone number\./);
await phone.fill("+7 701 123 45 67");
await page.getByRole("button", { name: "Confirm" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
await expect(page.getByRole("status")).toHaveText("Table booked. We will call you to confirm.");
await expect(page.getByRole("button", { name: "Book a table" })).toBeFocused();
});- The
wcag2a…wcag22aatags limit the scan to WCAG level A and AA rules; the set from the Playwright docs is extended withwcag22aa. - axe checks only what is in the DOM and visible right now. The Playwright docs specifically remind you to put the UI in the desired state before
analyze()— hence three scans: everything closed, menu open, dialog open. - The keyboard tests press real keys and check
toBeFocused(),aria-expanded,aria-selectedand panel visibility. toHaveAccessibleDescriptionchecks that the error actually became part of the field’s description rather than just appearing nearby.
$ npx playwright test --reporter=list
Running 5 tests using 1 worker
ok 1 tests\a11y.spec.ts:26:1 › axe: no violations in closed, menu-open and dialog-open states (943ms)
ok 2 tests\a11y.spec.ts:36:1 › disclosure menu: Enter opens, Escape closes and returns focus (135ms)
ok 3 tests\a11y.spec.ts:53:1 › tabs: one tab stop, arrows and Home/End move selection (128ms)
ok 4 tests\a11y.spec.ts:77:1 › dialog: focus moves in, stays in, Escape returns it (154ms)
ok 5 tests\a11y.spec.ts:101:1 › dialog form: error is announced, success goes to the live region (220ms)
5 passed (6.1s)The first run against the fixed page was red. axe found a target-size violation — WCAG 2.2 success criterion 2.5.8: the links in the open menu were 21 px tall and too close together, while the minimum is 24 × 24 CSS pixels or enough spacing. Links inside a sentence are exempt, menu items are not. The fix is one line of CSS:
/* WCAG 2.2, 2.5.8: a target is at least 24×24 CSS px */
#site-menu a { display: inline-block; padding: 8px 4px; }Now the before-and-after report. The script runs axe on both pages and presses Tab five times, recording where focus lands:
import AxeBuilder from "@axe-core/playwright";
import { chromium } from "@playwright/test";
const WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
const browser = await chromium.launch();
const context = await browser.newContext(); // AxeBuilder needs a page from a context
for (const path of ["/before.html", "/"]) {
const page = await context.newPage();
await page.goto(`http://127.0.0.1:4173${path}`);
const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
console.log(`\n${path}: ${violations.length} axe violation(s)`);
for (const v of violations) console.log(` ${v.id} (${v.impact}) × ${v.nodes.length}`);
const stops = [];
for (let i = 0; i < 5; i++) {
await page.keyboard.press("Tab");
stops.push(
await page.evaluate(() => {
const el = document.activeElement;
if (el === document.body) return "(page left)";
const name = el.getAttribute("aria-label") || el.textContent.trim().slice(0, 20);
return `${el.tagName.toLowerCase()} "${name}"`;
}),
);
}
console.log(` Tab ×5: ${stops.join(" → ")}`);
await page.close();
}
await browser.close();$ node report.mjs
/before.html: 4 axe violation(s)
button-name (critical) × 1
color-contrast (serious) × 1
image-alt (critical) × 1
label (critical) × 1
Tab ×5: button "" → input "" → (page left) → button "" → input ""
/: 0 axe violation(s)
Tab ×5: button "Menu" → button "Coffee" → div "Espresso, flat white" → button "Book a table" → (page left)This is the report’s main lesson. On the “before” page axe found four violations: a button without a name, insufficient contrast, an image without alt and a field without a label. But it said nothing about the three most serious problems: the menu, the tabs and “Book a table” can’t be reached by keyboard — Tab visits only the nameless button and the field, then leaves the page. Nor did it notice outline: none. On the fixed page Tab goes through the menu, the selected tab, its panel and the booking button.
Manual check: a minimal scenario
Automated tests lock in what you have already verified. A new screen is worth walking through by hand once. The minimal scenario takes a few minutes:
- Put the mouse away and go through the page with Tab and Shift+Tab: focus is always visible, the order matches the screen, and focus never disappears
- Every action works by keyboard: Enter and Space press buttons, Escape closes menus and dialogs, arrow keys work in tabs
- After a dialog or menu closes, focus returns to where it was opened from
- Turn on a screen reader — NVDA on Windows or VoiceOver on macOS and iOS — and go through the key scenario: every button and field has a clear name, errors and statuses are announced
- Zoom to 200% and check that content isn’t clipped and nothing covers the focused element
- Run axe in every UI state, not only on load
The APG warns that its examples don’t work around bugs in particular browsers and screen readers, and advises testing every combination that matters for your audience. In our experience, a sensible minimum for small-business websites is Chrome with NVDA and Safari with VoiceOver on iPhone.
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.