Automated accessibility testing usually means one thing: point a scanner at the page, let it check the initial HTML against a set of WCAG rules, and turn green. Missing alt text, an unlabeled input, low-contrast text at load. Caught. Done.
Then a user opens the menu, submits the empty form, expands the accordion, and lands in a state your scanner never saw. The newly revealed panel steals focus and never gives it back. The modal traps a keyboard user with no way out. None of that exists in the first render, so none of it was tested.
That is the gap this post is about. Accessibility is not a property of a page, it is a property of every state a page can reach, and most of those states only exist after someone interacts. The fix is to run your accessibility checks in a real browser, after the interaction, with two complementary tools: or is the engine behind most accessibility tooling. It is excellent at the rule-based checks: contrast, names, roles, ARIA misuse. The trick is not running axe. The trick is running it against the state that matters.
A small helper turns axe results into a readable failure instead of a wall of JSON:
import axe from "axe-core";
type AxeResults = import("axe-core").AxeResults;
type RunOptions = import("axe-core").RunOptions;
type ElementContext = import("axe-core").ElementContext;
/**
* Run axe-core against the page (or a scoped element) and throw a
* formatted error if there are any accessibility violations.
*/
export async function checkA11y(
context?: ElementContext,
options?: RunOptions
): Promise<AxeResults> {
const results = await axe.run(context ?? document, options);
if (results.violations.length) {
const report = results.violations
.map((v, i) => `${i + 1}. [${v.impact ?? "n/a"}] ${v.id}: ${v.help}\n ${v.helpUrl}`)
.join("\n\n");
throw new Error(
`Found ${results.violations.length} accessibility violation(s):\n\n${report}`
);
}
return results;
}
A baseline scan of the page that loads is the easy case:
import { twd, userEvent, screenDom } from "twd-js";
import { describe, it, beforeEach } from "twd-js/runner";
import { checkA11y } from "./helpers/axe";
// Only WCAG 2.0/2.1 A & AA rules. Skips axe "best-practice" rules
// (region, landmark-one-main, ...) which are recommendations, not failures.
const WCAG = {
runOnly: {
type: "tag" as const,
values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
},
};
describe("Accessibility", () => {
beforeEach(() => {
twd.clearRequestMockRules();
});
it("should have no WCAG 2 A/AA violations on the home page", async () => {
await twd.visit("/");
await checkA11y(document, WCAG);
});
});
That is the part every tool can do. Here is the part that needs a real browser and a real interaction: scanning a state that only exists after the user does something.
it("should stay accessible after form validation errors render", async () => {
await twd.mockRequest("getTodoList", {
method: "GET",
url: "/api/todos",
response: [],
status: 200,
});
await twd.visit("/todos");
await twd.waitForRequest("getTodoList");
// Submit the empty form to force the validation error messages to render.
const submitButton = await screenDom.findByRole("button", { name: "Create Todo" });
await userEvent.click(submitButton);
// Confirm the new dynamic content actually appeared before scanning.
const error = await screenDom.findByText("Title is required");
twd.should(error, "be.visible");
// Scope the scan to just the form so the report is about this component.
const form = await screenDom.findByTestId("todo-form");
await checkA11y(form, WCAG);
});
The error messages in that test do not exist in the initial HTML. They render after a failed submit. A static scan never reaches them, so it never checks whether they are associated with their fields, announced, or reachable. Driving the interaction first, then scoping the scan to the form, checks the state a user actually lands in.
Scoping matters too. Passing the form element instead of document keeps the report about the component you just exercised, rather than re-flagging everything else on the page.
Approach 2: keyboard navigation and focus order
axe will not tell you whether your tab order makes sense. It cannot. Whether Tab moves through controls in an order that matches the visual flow, whether focus is visible, whether a control is even reachable by keyboard: that is behavior, and you have to drive it.
Because TWD tests run in the real browser, userEvent drives real focus. So you can walk the page the way a keyboard user does and assert where focus lands at each step.
it("keyboard navigation should work", async () => {
await twd.visit("/chat");
const user = userEvent.setup();
// Tab from the top of the page. First stop should be the back link.
await user.tab();
const backToLanding = screenDom.getByRole("link", { name: "← Back to Landing" });
twd.should(backToLanding, "be.focused");
// Tab again. Focus should land on the prompt input, and typing should work.
await user.tab();
await user.keyboard("write some text");
const textarea = screenDom.getByLabelText("Main prompt input (required)");
twd.should(textarea, "have.text", "write some text");
});
This is a deterministic check of two things a scanner cannot see: the focus order is correct (back link, then input), and the input is reachable and usable by keyboard alone. If someone later drops a non-focusable div with a click handler into the flow, or reorders the DOM so tab order no longer matches the layout, this test fails. You find out in the run, not from a user report.
You can extend the same pattern to the cases that bite hardest: open a dialog and assert focus moved into it, close it and assert focus returned to the trigger, tab to the end of a menu and assert it does not escape into the page behind it.
Watch out with automated accessibility testing
Automated accessibility testing has a ceiling. Rule-based tools catch roughly a third of WCAG issues. axe is very good inside that third, and driving the interaction first stretches it to states a load-time scan cannot reach, but it is still a third. The rest needs a human: real screen reader testing, and judgment about whether labels, content order, and motion actually make sense.
So treat this as the regression net, not the whole strategy. It is the part you can run on every change, in CI, that stops obvious and post-interaction failures from sliding back in between manual audits.
Why this matters to us
We build the tool this runs on, for the full scope and methodology.
Try it
Both approaches are just tests, running in your real app, in a real browser:
npm install twd-js
- Docs:
- Source and issues: github.com/BRIKEV/twd
Point a scan at the page that loads if you want a baseline. Then write the one that clicks the button first. That second test is the one your users were waiting for.
SOCIAL SHARE CARD GENERATOR