Skip to content

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.