Accessibility Testing
This lesson explains how to test Web Components for accessibility, including ARIA roles, labels, keyboard support, focus management, and automated checks with axe-core and Playwright.
Why Accessibility Testing Matters for Web Components
Custom elements are still part of the accessibility tree. If a Web
Component lacks labels, keyboard support, or proper roles, assistive
technologies cannot use it reliably. Accessibility testing ensures
your reusable UI works for everyone, not only sighted mouse users.
Shadow DOM does not remove accessibility responsibilities. You must
test semantic markup, ARIA usage, focus behavior, and visible states
inside encapsulated components just like native HTML controls.
| Concept | Description |
| Semantic HTML | Use native elements such as button and input |
| ARIA | Add roles and states only when native HTML is not enough |
| Keyboard Support | Tab, Enter, Space, Escape, and arrow keys where needed |
| Focus Management | Visible focus and logical tab order |
| Automated Testing | axe-core, Playwright, and lint rules |
| Manual Testing | Keyboard-only and screen reader verification |
Accessible Component Example
class AppButton extends HTMLElement {
connectedCallback() {
if (this.querySelector("button")) return;
const button = document.createElement("button");
button.type = "button";
button.textContent =
this.getAttribute("label") || "Click me";
if (this.hasAttribute("disabled")) {
button.disabled = true;
}
this.appendChild(button);
}
}
customElements.define("app-button", AppButton);
it("uses a native button with accessible name", () => {
const element = document.createElement("app-button");
element.setAttribute("label", "Save profile");
document.body.appendChild(element);
const button = element.querySelector("button");
expect(button).not.toBeNull();
expect(button.textContent).toBe("Save profile");
expect(button.disabled).toBe(false);
element.remove();
});
Prefer native HTML elements inside custom elements whenever possible.
A real <button> gives you keyboard support and
accessibility behavior for free.
What to Test for Accessibility
| Area | Test | Example |
| Name | Control has an accessible name | Button text or aria-label |
| Role | Correct semantic or ARIA role | role="dialog" |
| State | Expanded, selected, disabled states exposed | aria-expanded="true" |
| Keyboard | All actions work without a mouse | Enter opens menu |
| Focus | Focus moves logically and stays visible | Dialog traps focus |
| Color Contrast | Text meets WCAG contrast requirements | Error text on background |
| Live Regions | Dynamic updates are announced | aria-live="polite" |
Automated Testing with axe-core
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
it("alert banner has no accessibility violations", async () => {
const banner = document.createElement("alert-banner");
banner.setAttribute("type", "success");
banner.textContent = "Profile updated";
document.body.appendChild(banner);
const results = await axe(banner);
expect(results).toHaveNoViolations();
banner.remove();
});
axe-core catches many common issues such as missing labels, invalid
ARIA, low contrast, and duplicate IDs. Run it on each component and
on composed examples that use slots.
Keyboard Navigation Testing
class MenuButton extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button type="button" aria-haspopup="true" aria-expanded="false">
Options
</button>
<ul hidden role="menu">
<li role="menuitem">Edit</li>
<li role="menuitem">Delete</li>
</ul>
`;
this.button = this.querySelector("button");
this.menu = this.querySelector('[role="menu"]');
this.button.addEventListener("click", () => {
const expanded =
this.button.getAttribute("aria-expanded") === "true";
this.button.setAttribute(
"aria-expanded",
String(!expanded)
);
this.menu.hidden = expanded;
});
}
}
it("toggles menu with keyboard-accessible button", () => {
const menuButton = document.createElement("menu-button");
document.body.appendChild(menuButton);
const button = menuButton.querySelector("button");
const menu = menuButton.querySelector('[role="menu"]');
button.click();
expect(button.getAttribute("aria-expanded")).toBe("true");
expect(menu.hidden).toBe(false);
menuButton.remove();
});
Test keyboard flows explicitly: Tab to the control, activate with
Enter or Space, move with arrow keys when appropriate, and close
with Escape for overlays and menus.
Focus Management Testing
class AppDialog extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div role="dialog" aria-modal="true" aria-labelledby="title">
<h2 id="title"><slot name="title"></slot></h2>
<slot></slot>
<button type="button" data-close>Close</button>
</div>
`;
this.previousFocus = document.activeElement;
this.querySelector("[data-close]").focus();
}
disconnectedCallback() {
this.previousFocus?.focus();
}
}
it("moves focus into dialog on open", () => {
const trigger = document.createElement("button");
trigger.textContent = "Open";
document.body.appendChild(trigger);
trigger.focus();
const dialog = document.createElement("app-dialog");
dialog.innerHTML = `<span slot="title">Confirm</span>`;
document.body.appendChild(dialog);
expect(document.activeElement).toBe(
dialog.querySelector("[data-close]")
);
dialog.remove();
trigger.remove();
});
Dialogs, drawers, and modals must manage focus correctly. Test that
focus moves into the component when opened and returns to a sensible
element when closed.
Accessibility Inside Shadow DOM
class SearchField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<label for="query">Search</label>
<input id="query" type="search" />
`;
}
}
it("associates label with input inside shadow root", () => {
const field = document.createElement("search-field");
document.body.appendChild(field);
const label = field.shadowRoot.querySelector("label");
const input = field.shadowRoot.querySelector("input");
expect(label.getAttribute("for")).toBe("query");
expect(input.id).toBe("query");
field.remove();
});
Labels, descriptions, and controls inside Shadow DOM must still be
correctly associated. Test those relationships directly when they
affect screen reader output.
Common ARIA Patterns to Test
| Component | Key ARIA | Keyboard |
| Tabs | role="tablist", aria-selected | Arrow keys between tabs |
| Accordion | aria-expanded | Enter toggles panel |
| Dialog | role="dialog", aria-modal | Escape closes dialog |
| Tooltip | aria-describedby | Focus or hover reveals text |
| Switch | role="switch", aria-checked | Space toggles state |
Manual Accessibility Testing
- Navigate the component using only the keyboard.
- Verify focus order matches visual order.
- Check that visible focus indicators are not removed.
- Test with VoiceOver, NVDA, or JAWS for announcements.
- Zoom the page to 200% and confirm layout remains usable.
- Verify disabled and error states are perceivable.
- Test reduced motion preferences when animations are used.
Automated vs Manual Testing
| Method | Catches | Limitation |
| axe-core | Missing labels, invalid ARIA, contrast issues | Cannot judge full UX quality |
| Unit tests | Roles, attributes, focus behavior | Needs explicit test cases |
| Keyboard testing | Operability without mouse | Manual or scripted interaction required |
| Screen reader testing | Announcement quality and reading order | Slower, requires human judgment |
When to Run Accessibility Tests
- Every reusable component before publishing to a design system.
- Whenever ARIA attributes or keyboard handlers change.
- After adding Shadow DOM, slots, or dynamic content updates.
- Before release of dialogs, menus, tabs, and form controls.
- During refactors that touch focus or visible state logic.
- As part of CI alongside unit and integration tests.
Accessibility Testing Use Cases
- Design system buttons, inputs, and toggles.
- Modal dialogs and drawer components.
- Tab panels and accordions.
- Autocomplete and combobox widgets.
- Toast notifications and live regions.
- Custom form controls submitted through ElementInternals.
Accessibility Testing Best Practices
- Prefer native HTML before adding ARIA.
- Test keyboard behavior for every interactive component.
- Include axe checks in unit or component test suites.
- Test slotted content and named slots, not only default markup.
- Verify focus restoration for overlays and dialogs.
- Document expected keyboard shortcuts and ARIA contracts.
- Combine automated and manual testing for best coverage.
Common Accessibility Mistakes
- Assuming Shadow DOM hides accessibility requirements.
- Using
div click handlers instead of buttons. - Adding redundant or incorrect ARIA roles.
- Removing focus outlines without replacing them.
- Not exposing disabled, expanded, or selected states.
- Testing only with a mouse and never with keyboard or screen readers.
- Relying on color alone to communicate state or errors.
Key Takeaways
- Web Components must be accessible even when encapsulated.
- Test names, roles, states, keyboard support, and focus behavior.
- axe-core helps catch many issues early in CI.
- Manual keyboard and screen reader testing still matters.
- Accessible custom elements are safer to reuse across products.
Pro Tip
Build accessibility tests around real user actions: Tab to the
element, activate it with Enter, and assert the resulting role,
label, and focus behavior. That catches more issues than checking
static HTML alone.