Accessibility Testing for Headless Component Libraries: axe, jest-axe, and Beyond

Accessibility Testing for Headless Component Libraries: axe, jest-axe, and Beyond

Headless component libraries — shadcn/ui, Radix UI, Headless UI, Ark UI — are built with accessibility as a core feature. But "accessible by design" doesn't mean "accessible by default in your application." Your composition, custom styles, and application logic can break the accessibility guarantees these libraries provide. Systematic accessibility testing catches those regressions.

Why Headless Libraries Can Still Have a11y Failures

Even with accessible primitives, your code can introduce violations:

  • Missing labels: A button that wraps an icon needs aria-label; the library can't provide it
  • Broken focus management: Custom z-index or overflow: hidden can trap or block focus
  • Color contrast failures: Tailwind color utilities can easily create low-contrast text
  • Keyboard trap after close: A modal that closes but doesn't return focus to the trigger
  • Dynamic content not announced: Content added to the DOM after interaction without proper live regions

The Three Levels of Accessibility Testing

Effective a11y testing combines automated, semi-automated, and manual techniques:

  1. Automated (jest-axe): Catches ~30% of WCAG issues automatically
  2. Keyboard testing (RTL + userEvent): Verifies focus management and keyboard operability
  3. Screen reader testing (manual or Playwright): Verifies announcements and reading order

Don't rely on any single level. Automated tools miss what keyboard tests catch; keyboard tests miss what screen reader tests catch.

Setting Up jest-axe

npm install --save-dev jest-axe axe-core
// vitest.setup.ts
import "@testing-library/jest-dom";
import { configureAxe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);

// Configure axe for your WCAG level
export const axe = configureAxe({
  rules: {
    // Disable rules that require browser rendering (color contrast in jsdom is unreliable)
    "color-contrast": { enabled: false },
  },
});

Pattern 1: Component-Level axe Scans

Run axe on every interactive component in every significant state:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "./vitest.setup";

// shadcn/ui Dialog
it("Dialog: no a11y violations when closed", async () => {
  const { container } = render(<TestDialog />);
  expect(await axe(container)).toHaveNoViolations();
});

it("Dialog: no a11y violations when open", async () => {
  const user = userEvent.setup();
  const { container } = render(<TestDialog />);

  await user.click(screen.getByRole("button", { name: "Open" }));

  // Important: scan container after state change
  expect(await axe(container)).toHaveNoViolations();
});

// Radix UI Select
it("Select: no a11y violations when closed", async () => {
  const { container } = render(<FrameworkSelect onChange={vi.fn()} />);
  expect(await axe(container)).toHaveNoViolations();
});

it("Select: no a11y violations when open", async () => {
  const user = userEvent.setup();
  const { container } = render(<FrameworkSelect onChange={vi.fn()} />);

  await user.click(screen.getByRole("combobox"));
  expect(await axe(container)).toHaveNoViolations();
});

Pattern 2: Form Accessibility

Forms are the highest-risk area for a11y failures. Test label associations, error announcements, and required field marking:

it("form fields have accessible labels", () => {
  render(<LoginForm onSubmit={vi.fn()} />);

  // Each input should be associated with a label
  const emailInput = screen.getByRole("textbox", { name: "Email" });
  const passwordInput = screen.getByLabelText("Password");

  expect(emailInput).toBeInTheDocument();
  expect(passwordInput).toBeInTheDocument();
});

it("validation errors are associated with their inputs", async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Submit" }));

  await waitFor(() => {
    const emailInput = screen.getByRole("textbox", { name: "Email" });
    const errorMessage = screen.getByText("Email is required");

    // Input should reference the error via aria-describedby
    const describedBy = emailInput.getAttribute("aria-describedby");
    expect(document.getElementById(describedBy!)).toHaveTextContent("Email is required");
  });
});

it("required fields are marked as required", () => {
  render(<LoginForm onSubmit={vi.fn()} />);

  expect(screen.getByRole("textbox", { name: "Email" })).toHaveAttribute("aria-required", "true");
});

it("loading state disables submit and announces loading", async () => {
  render(<LoginForm onSubmit={vi.fn()} isLoading />);

  const submit = screen.getByRole("button", { name: /Submitting|Loading|Saving/i });
  expect(submit).toBeDisabled();
  expect(submit).toHaveAttribute("aria-busy", "true");
});

it("form has no a11y violations in all states", async () => {
  const user = userEvent.setup();
  const { container } = render(<LoginForm onSubmit={vi.fn()} />);

  // Initial state
  expect(await axe(container)).toHaveNoViolations();

  // After triggering validation errors
  await user.click(screen.getByRole("button", { name: "Submit" }));
  await waitFor(() => screen.getByText("Email is required"));
  expect(await axe(container)).toHaveNoViolations();
});

Pattern 3: Keyboard Navigation Testing

Keyboard testing verifies that every interaction is reachable without a mouse:

it("entire form is navigable by keyboard", async () => {
  const user = userEvent.setup();
  render(
    <form>
      <LoginForm onSubmit={vi.fn()} />
    </form>
  );

  // Tab through all focusable elements
  await user.tab();
  expect(screen.getByLabelText("Email")).toHaveFocus();

  await user.tab();
  expect(screen.getByLabelText("Password")).toHaveFocus();

  await user.tab();
  expect(screen.getByRole("button", { name: "Submit" })).toHaveFocus();

  // Submit with Enter key
  await user.keyboard("{Enter}");
  // Submission should be triggered
});

it("dropdown menu is fully keyboard operable", async () => {
  const user = userEvent.setup();
  const handleAction = vi.fn();
  render(<ActionsDropdown onAction={handleAction} />);

  // Focus trigger with Tab
  await user.tab();
  expect(screen.getByRole("button", { name: "Actions" })).toHaveFocus();

  // Open with Enter
  await user.keyboard("{Enter}");
  expect(screen.getByRole("menu")).toBeInTheDocument();

  // Navigate items
  await user.keyboard("{ArrowDown}"); // First item focused
  await user.keyboard("{ArrowDown}"); // Second item focused
  await user.keyboard("{Enter}");     // Activate

  expect(handleAction).toHaveBeenCalled();

  // Verify focus returns to trigger after close
  await waitFor(() => {
    expect(screen.getByRole("button", { name: "Actions" })).toHaveFocus();
  });
});

it("modal returns focus to trigger after close", async () => {
  const user = userEvent.setup();
  render(<TestDialog />);

  const trigger = screen.getByRole("button", { name: "Open" });
  await user.click(trigger);

  // Focus should move into dialog
  expect(document.activeElement).not.toBe(trigger);

  await user.keyboard("{Escape}");

  // Focus should return to trigger
  await waitFor(() => {
    expect(trigger).toHaveFocus();
  });
});

Pattern 4: ARIA Live Region Testing

Dynamic content changes must be announced to screen readers via live regions:

it("error banner is announced to screen readers", async () => {
  const { rerender } = render(<AlertBanner message={null} />);

  // No announcement initially
  expect(screen.queryByRole("alert")).not.toBeInTheDocument();

  // Render error
  rerender(<AlertBanner message="Something went wrong" />);

  // Alert role provides live region semantics
  expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong");
});

it("toast notification has correct live region role", async () => {
  const user = userEvent.setup();
  render(
    <>
      <ToastTrigger />
      <Toaster />
    </>
  );

  await user.click(screen.getByRole("button", { name: "Notify" }));

  await waitFor(() => {
    // Toasts should use role="status" (polite) or role="alert" (assertive)
    const toast = screen.getByRole("status");
    expect(toast).toHaveTextContent("Saved successfully");
  });
});

it("loading state is announced", async () => {
  const { rerender } = render(<DataTable isLoading={false} data={mockData} />);

  rerender(<DataTable isLoading={true} data={[]} />);

  // Loading should be announced
  expect(screen.getByRole("status")).toHaveTextContent(/loading/i);
});

Pattern 5: Color Contrast (Semi-Automated)

jest-axe's color contrast rules are unreliable in jsdom because jsdom doesn't compute CSS. Use Playwright or a visual regression tool for this:

// playwright/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("home page has no color contrast violations", async ({ page }) => {
  await page.goto("/");

  const results = await new AxeBuilder({ page })
    .withRules(["color-contrast"])
    .analyze();

  expect(results.violations).toEqual([]);
});

test("login form has no color contrast violations", async ({ page }) => {
  await page.goto("/login");

  const results = await new AxeBuilder({ page })
    .withRules(["color-contrast"])
    .analyze();

  expect(results.violations).toEqual([]);
});

Pattern 6: Icon Button Labels

Icon-only buttons are a frequent a11y failure — they have no visible text for screen readers:

// BAD: No accessible label
function DeleteButton({ onClick }: { onClick: () => void }) {
  return (
    <button onClick={onClick}>
      <TrashIcon />
    </button>
  );
}

// GOOD: Explicit aria-label
function DeleteButton({ onClick }: { onClick: () => void }) {
  return (
    <button onClick={onClick} aria-label="Delete item">
      <TrashIcon aria-hidden="true" />
    </button>
  );
}

it("delete button has accessible label", () => {
  render(<DeleteButton onClick={vi.fn()} />);
  expect(screen.getByRole("button", { name: "Delete item" })).toBeInTheDocument();
});

it("delete icon is hidden from screen readers", () => {
  render(<DeleteButton onClick={vi.fn()} />);
  const icon = screen.getByRole("button").querySelector("svg");
  expect(icon).toHaveAttribute("aria-hidden", "true");
});

Pattern 7: Image Alt Text

it("decorative images have empty alt text", () => {
  render(<HeroSection />);
  const decorativeImages = screen.getAllByRole("presentation");
  decorativeImages.forEach((img) => {
    expect(img).toHaveAttribute("alt", "");
  });
});

it("informative images have descriptive alt text", () => {
  render(<ProductCard product={mockProduct} />);
  const productImage = screen.getByAltText("Blue wireless headphones, front view");
  expect(productImage).toBeInTheDocument();
});

Building an Accessibility Test Suite

Structure your a11y tests alongside your component tests:

src/
  components/
    ui/
      Button.tsx
      Button.test.tsx     ← behavior tests
      Button.a11y.test.tsx ← accessibility-specific tests
    Dialog.tsx
    Dialog.test.tsx
    Dialog.a11y.test.tsx

A minimal accessibility test file template:

// Button.a11y.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "../../test/axe-setup";
import { Button } from "./Button";

describe("Button accessibility", () => {
  it("has no violations in default state", async () => {
    const { container } = render(<Button>Click me</Button>);
    expect(await axe(container)).toHaveNoViolations();
  });

  it("has no violations when disabled", async () => {
    const { container } = render(<Button disabled>Click me</Button>);
    expect(await axe(container)).toHaveNoViolations();
  });

  it("has no violations when loading", async () => {
    const { container } = render(<Button aria-busy={true} disabled>Saving...</Button>);
    expect(await axe(container)).toHaveNoViolations();
  });

  it("is reachable via keyboard", async () => {
    const user = userEvent.setup();
    render(<Button onClick={vi.fn()}>Click me</Button>);
    await user.tab();
    expect(screen.getByRole("button")).toHaveFocus();
  });

  it("activates with Enter and Space keys", async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    await user.tab();
    await user.keyboard("{Enter}");
    expect(handleClick).toHaveBeenCalledTimes(1);

    await user.keyboard(" ");
    expect(handleClick).toHaveBeenCalledTimes(2);
  });
});

Monitoring Accessibility in Production with HelpMeTest

Automated unit tests catch accessibility regressions at development time. HelpMeTest's continuous monitoring catches them in production — including on real browsers where CSS and JavaScript actually run:

*** Settings ***
Library    Browser
Library    AxeLibrary

*** Test Cases ***
Verify Login Page Accessibility
    New Browser    chromium    headless=True
    New Page    https://yourapp.com/login
    Run Axe    include=[main]    exclude=[iframe]
    Get Axe Results Should Have No Violations

HelpMeTest runs these checks on every deployment, alerting your team when an accessibility regression slips through to production.

Summary

A complete accessibility testing strategy for headless component libraries:

  1. jest-axe for automated scanning: Run on every component in every significant state (closed, open, error, loading)
  2. Disable color-contrast in jsdom: Use Playwright + axe for real color contrast checks
  3. Test keyboard navigation explicitly: Tab order, arrow key navigation, Enter/Space activation, Escape to close
  4. Verify focus management: Focus enters dialog, focus returns to trigger after close
  5. Assert on ARIA live regions: role="alert" for errors, role="status" for notifications
  6. Label all icon buttons: Every button needs accessible text — test with getByRole("button", { name: "..." })
  7. Structure tests in .a11y.test.tsx files: Keep accessibility tests separate for easy auditing

Read more