Accessibility used to arrive in our inbox as a nice-to-have. It now arrives as a procurement blocker. Since the European Accessibility Act's June 2025 application date, public-sector and many private buyers in the EU ask for a conformance statement before they sign; US clients ask for an ACR/VPAT built on WCAG 2.2 AA. Meanwhile most of the Angular front ends we inherit have never been keyboard-tested once.
This tutorial is the audit-and-fix pass we run on a MEAN front end, using Angular 22 (standalone, signals, zoneless) and an Express 5 API. Nothing here requires a redesign. The goal is a measurable baseline, a fix list ordered by risk, and a CI gate so the next sprint does not undo the work.
1. Get a baseline before you touch anything
Do not start with opinions. Start with numbers you can re-run.
npm i -D @axe-core/playwright @playwright/test axe-core
npx playwright install --with-deps chromium
Create e2e/a11y.baseline.spec.ts:
import { test } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { writeFileSync, mkdirSync } from 'node:fs';
const ROUTES = ['/', '/login', '/tasks', '/tasks/new', '/settings/profile'];
test('capture axe baseline', async ({ page }) => {
mkdirSync('a11y-report', { recursive: true });
const rows: unknown[] = [];
for (const route of ROUTES) {
await page.goto(route);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
rows.push({
route,
violations: results.violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.length,
help: v.help,
})),
});
}
writeFileSync('a11y-report/baseline.json', JSON.stringify(rows, null, 2));
});
Run it and commit baseline.json. On a typical inherited Angular app the first run returns a handful of rule families: colour contrast, missing form labels, buttons that are really divs, dialogs with no accessible name, and route changes that never announce themselves.
Two honest caveats. Automated tooling catches roughly a third of WCAG failures, and axe reports nodes, not user journeys. So the baseline is a floor, not a conformance claim.
2. The manual pass that automation cannot do
Budget one hour per major flow and do exactly four things.
Keyboard only. Unplug the mouse. Tab through login, create-task and checkout. Note anywhere focus disappears, loops inside a widget, or lands behind an overlay. Keyboard traps are the single most common blocker we find.
Zoom to 400%. In Chrome set the viewport to 1280px wide and zoom to 400% (WCAG 1.4.10 Reflow). Horizontal scrollbars and clipped toolbars show up immediately.
Screen reader smoke test. NVDA on Windows or VoiceOver on macOS. Read one form end to end. If a field's error is invisible to the reader, that is 3.3.1 failing.
WCAG 2.2's newer criteria. These are the ones nobody has heard of and enterprise auditors love:
- 2.4.11 Focus Not Obscured — your sticky header must not cover the focused element.
- 2.5.7 Dragging Movements — every drag reorder needs a non-drag alternative (move up/move down buttons).
- 2.5.8 Target Size (Minimum) — interactive targets at least 24x24 CSS px, or adequately spaced. Icon-button toolbars fail this constantly.
- 3.3.7 Redundant Entry — do not ask for the same address twice in a wizard.
- 3.3.8 Accessible Authentication — no cognitive-function test with no alternative; let password managers paste, and do not block paste into OTP fields.
3. Fixes in Angular 22
Semantics first, ARIA second
The cheapest win is deleting ARIA. A native <button> is focusable, activates on Enter and Space, and announces its role for free.
<!-- before -->
<div class="icon-btn" (click)="remove(task)" role="button" aria-label="Delete"></div>
<!-- after -->
<button type="button" class="icon-btn" (click)="remove(task)">
<svg aria-hidden="true" focusable="false"><!-- ... --></svg>
<span class="sr-only">Delete task {{ task.title }}</span>
</button>
That sr-only span also fixes 2.4.6 (descriptive names) and gives every row's delete button a unique name, which matters when a reader lists the buttons on the page.
Also watch type: a <button> inside a form without type="button" submits it.
Forms: label, describe, and announce errors
<label for="email">Work email</label>
<input
id="email"
type="email"
name="email"
autocomplete="email"
[attr.aria-invalid]="emailInvalid() ? 'true' : null"
[attr.aria-describedby]="emailInvalid() ? 'email-error email-hint' : 'email-hint'"
[(ngModel)]="email" />
<p id="email-hint" class="hint">We only use this for project updates.</p>
@if (emailInvalid()) {
<p id="email-error" class="error">Enter an email address such as name@example.com.</p>
}
Three rules we apply mechanically:
- Every control has a programmatic label (
<label for>, oraria-labelif visually there is no text). Placeholders are not labels. autocompletetokens on name, email, address and password fields — that is 1.3.5 Identify Input Purpose, and it makes 3.3.8 easy because password managers work.- Error text is linked with
aria-describedby, not merely painted red. Colour alone fails 1.4.1.
On submit, move focus to the first invalid control rather than to a summary nobody reads:
private focusFirstInvalid(host: HTMLElement) {
const el = host.querySelector<HTMLElement>('[aria-invalid="true"]');
el?.focus();
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
Server-side errors have to be reachable too
An Express 5 API that returns a flat string is hard to make accessible. Return field-scoped errors so the client can bind them to the right input:
// api/src/middleware/validate.js (Express 5 + Zod)
export const validate = (schema) => (req, res, next) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
message: 'Validation failed',
fields: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
req.body = parsed.data;
next();
};
Dialogs: use the platform
Hand-rolled modals are where focus management goes to die. <dialog> with showModal() gives you focus trapping, Escape handling and inert background content from the browser.
import { Component, ElementRef, effect, input, output, viewChild } from '@angular/core';
@Component({
selector: 'sp-confirm-dialog',
template: `
<dialog #dlg aria-labelledby="confirm-title" (close)="closed.emit()">
<h2 id="confirm-title">{{ title() }}</h2>
<p>{{ message() }}</p>
<button type="button" (click)="dlg.close('cancel')">Cancel</button>
<button type="button" (click)="dlg.close('confirm')">Delete</button>
</dialog>
`,
})
export class ConfirmDialog {
title = input.required<string>();
message = input.required<string>();
open = input(false);
closed = output<void>();
private dlg = viewChild.required<ElementRef<HTMLDialogElement>>('dlg');
constructor() {
effect(() => {
const el = this.dlg().nativeElement;
if (this.open() && !el.open) el.showModal();
if (!this.open() && el.open) el.close();
});
}
}
One thing the platform does not do: return focus to the element that opened the dialog in every browser/AT combination. Capture it yourself before showModal() and restore it on close.
If you are on Angular Material, prefer its MatDialog and the cdkTrapFocus / LiveAnnouncer primitives from @angular/cdk/a11y instead of writing your own.
Route changes in an SPA are silent — fix that
When a router navigation swaps the view, a screen reader user hears nothing and focus stays on the link they just left. Announce the new page title and move focus to the main region.
import { inject, Injectable } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { filter } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class RouteAnnouncer {
private router = inject(Router);
private titleSvc = inject(Title);
private announcer = inject(LiveAnnouncer);
start() {
this.router.events
.pipe(filter((e) => e instanceof NavigationEnd))
.subscribe(() => {
const title = this.titleSvc.getTitle();
this.announcer.announce(`${title} page loaded`, 'polite');
const main = document.querySelector<HTMLElement>('main');
main?.focus({ preventScroll: false });
});
}
}
Give the landmark tabindex="-1" so it can receive focus, and add a skip link as the first focusable element on the page:
<a class="skip-link" href="#main">Skip to main content</a>
<header>...</header>
<main id="main" tabindex="-1">
<router-outlet />
</main>
The skip link must become visible when focused — a .skip-link:focus { position: static; } style, not display: none.
Contrast, focus rings and motion
- Text contrast 4.5:1 (3:1 for large text); UI component boundaries and focus indicators 3:1 (1.4.11). Fix this in design tokens, not per component, or it regresses next sprint.
- Never
outline: nonewithout a replacement. A 2px outline with a 2px offset satisfies 2.4.7 and 2.4.13. - Respect
prefers-reduced-motionfor Angular animations and any parallax:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
- Icon-only buttons: pad to at least 24x24 CSS px of hit area for 2.5.8.
SSR helps, but check the hydrated DOM
If the app uses Angular SSR with incremental hydration, remember that a deferred block is not in the DOM until it hydrates. Audit the hydrated page, and make sure nothing important is behind a block that only hydrates on hover — hover is not a keyboard event.
4. Lock it in: the CI gate
A one-off audit decays in about six weeks. Turn the baseline into a test that fails the build on new violations.
// e2e/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const ROUTES = ['/', '/login', '/tasks', '/tasks/new', '/settings/profile'];
// Rules we have not fixed yet, each with an owner and a date. Shrink this list, never grow it.
const KNOWN: Record<string, string[]> = {
'/settings/profile': ['color-contrast'], // design tokens, due next sprint
};
for (const route of ROUTES) {
test(`a11y: ${route}`, async ({ page }) => {
await page.goto(route);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.disableRules(KNOWN[route] ?? [])
.analyze();
const summary = results.violations
.map((v) => `${v.id} (${v.impact}, ${v.nodes.length} nodes): ${v.help}`)
.join('\n');
expect(results.violations, summary).toEqual([]);
});
}
Then in GitHub Actions:
name: a11y
on: [pull_request]
jobs:
axe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build -- --configuration production
- run: npx playwright test e2e/a11y.spec.ts
- uses: actions/upload-artifact@v4
if: always()
with: { name: a11y-report, path: a11y-report/ }
Two extra guards worth the five minutes: eslint-plugin-jsx-a11y has no Angular equivalent, but the Angular ESLint template rules (@angular-eslint/template/click-events-have-key-events, .../interactive-supports-focus, .../label-has-associated-control, .../valid-aria) catch a real share of regressions at author time. And add a Lighthouse or pa11y-ci run on the built SSR output for pages your Playwright routes do not reach.
5. What you hand the client
An accessibility engagement is not finished when the tests go green. Produce three artefacts:
- An audit report — criterion by criterion for WCAG 2.2 AA, each marked supports / partially supports / does not support, with the offending route and a screenshot.
- An accessibility statement for the site, naming the standard, the known gaps, the remediation dates and a contact route for complaints. The EAA and most public-sector rules expect a published statement, not just a passing test suite.
- The CI gate plus the shrinking
KNOWNlist, so the next team inherits evidence rather than folklore.
Realistic effort
On a mid-sized Angular app (40-60 components, 15 routes) we typically see: two days for the baseline and manual passes, three to five days for semantics, forms, focus and dialogs, one to two days for contrast tokens, and half a day for the CI gate. Colour and design-token work is usually the long pole because it needs a designer in the room.
The payoff is not only compliance. Every fix above — real buttons, labelled inputs, managed focus, announced navigation — makes the app faster to use for everyone, and it makes the front end far easier to test.
If you need an accessibility audit of an Angular or MEAN front end, a remediation sprint, or a conformance statement you can put in front of a procurement team, get in touch. Our UI Design with MEAN Stack and MEAN Stack Testing teams do this work together, so the fixes ship with tests that keep them fixed.