The WCAG AA Checklist Every Angular Component Should Pass Before You Ship
Keyboard & Focus
This is where most "accessible" component libraries quietly fall apart.
- Everything interactive works with the keyboard alone. π€ Unplug your mouse and drive the component. Every action reachable by click must be reachable by Tab/Enter/Space/arrows.
- You used native elements where you could. π€/π€ A
<button>is keyboard-operable, focusable and announced for free. A<div (click)>is none of those. If you're addingrole="button"and akeydownhandler to adiv, stop and use abutton. - Focus is always visible. π€ Never
outline: nonewithout a replacement. Use:focus-visibleso keyboard users get a clear ring and mouse users aren't bothered::focus-visible { outline: 2px solid var(--ngbr-color-focus); outline-offset: 2px; } - Tab order follows reading order. π€ No positive
tabindex. Let the DOM order do the work; if the visual order disagrees with the DOM, fix the DOM. - Focus is trapped in modal dialogs and restored to the trigger on close. π€ Don't hand-roll this - use the CDK:
<div cdkTrapFocus cdkTrapFocusAutoCapture role="dialog" aria-modal="true" aria-labelledby="title"> <h2 id="title">Confirm deletion</h2> β¦ </div> - No keyboard traps anywhere else. π€ You can always Tab out of a component. The only legitimate trap is an open modal.
Focus Management on Dynamic Changes
The single most overlooked area, and almost entirely invisible to tooling.
- Route changes move focus. π€ On navigation, an SPA leaves focus on the old (now-gone) element. Send it to the new page's heading:
this.router.events.pipe(filter(e => e instanceof NavigationEnd)) .subscribe(() => { const h1 = this.doc.querySelector('main h1') as HTMLElement | null; h1?.focus(); // h1 needs tabindex="-1" }); - Opening a menu/drawer/dialog moves focus into it; closing returns focus to the trigger. π€
- Deleting an item moves focus somewhere sensible (the next row, or the list heading) - never to
<body>. π€ - There's a skip link to bypass the nav and jump to
<main>. π€/π€
Semantics & ARIA
The first rule of ARIA is: don't use ARIA if a native element will do.
- Landmarks are present: one
<main>, plus<nav>,<header>,<footer>as needed. π€ - One
<h1>per page, and headings don't skip levels. π€ Heading structure is how screen-reader users navigate - it's a table of contents, not styling. - Custom widgets carry the right role and state - and put them in the host object, not
@HostBinding:@Component({ selector: 'ngbr-switch', host: { 'role': 'switch', '[attr.aria-checked]': 'checked()', '[attr.aria-disabled]': 'disabled() || null', 'tabindex': '0', '(keydown.enter)': 'toggle()', '(keydown.space)': 'toggle(); $event.preventDefault()', }, }) export class NgbrSwitch { /* checked = signal(false) β¦ */ } - Icon-only buttons have an accessible name. π€
aria-label="Close"- an icon font or SVG is silent otherwise. - No
aria-*attribute references a missingid. π€ Danglingaria-describedby/aria-labelledbyannounce nothing. - You didn't slap ARIA on a native element that already had the semantics. π€
<button role="button">is redundant;<nav role="navigation">is noise.
Forms & Errors
- Every input has a real
<label>with a matchingfor/id- not just a placeholder. π€ - Required fields expose it programmatically:
[required]oraria-required="true", not just an asterisk. π€/π€ - Errors are linked to their field and announced. π€ Tie the message in with
aria-describedby, fliparia-invalid, and give itrole="alert"so it's read on appearance:<label for="email">Email</label> <input id="email" type="email" formControlName="email" [attr.aria-invalid]="showError()" [attr.aria-describedby]="showError() ? 'email-error' : null"> @if (showError()) { <p id="email-error" role="alert">Enter a valid email address.</p> } - On submit, focus moves to the first error (or an error summary). π€ Don't make a keyboard user hunt for what went wrong.
- Error state isn't signalled by colour alone (see Β§5). π€
Colour, Contrast & Motion
- Text meets AA contrast - 4.5:1 for body, 3:1 for large text and UI components/focus indicators. π€
- Information is never conveyed by colour alone. π€ A red border needs an icon or text too - that's WCAG 1.4.1, and it's invisible to a colour-blind user and to AXE. This bites hardest in charts and status indicators.
- The UI survives 200% zoom and 400% reflow without horizontal scrolling or clipping. π€
- You honour
prefers-reduced-motion. π€/π€ Animations can trigger vestibular disorders:@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } - It works in both light and dark themes - derive colours from tokens so contrast holds in both, rather than hardcoding. π€
Dynamic Content & Screen-Reader UX
The stuff that separates "passes the audit" from "actually usable."
- Async updates are announced. π€ "Showing 12 results," "Saved," "Loadingβ¦" - use the CDK's
LiveAnnouncerrather than hand-rolling a live region:private announcer = inject(LiveAnnouncer); // after a filter runs: this.announcer.announce(`${count} results`, 'polite'); - Loading and busy states are exposed, e.g.
aria-busy="true"on the region being updated. π€ - Images have meaningful
alt(and decorative ones havealt=""). π€ WithNgOptimizedImage,altis mandatory - lean on it. - Data visualisations have a text equivalent. π€ An SVG chart is invisible to a screen reader. Behind every chart there should be a visually-hidden
<table>of the same data - this is the single hardest item on the list, and the one no tool will ever flag.
How to Actually Run This
- Automated pass - wire
@axe-core/playwright(orjest-axe) into CI so the π€ items can never regress. This is table stakes; it just can't be the whole test. - Keyboard pass - unplug the mouse, Tab through the whole flow. Most π€ issues surface in 60 seconds.
- Screen-reader pass - VoiceOver (β+F5 on macOS) or NVDA (free on Windows). You don't need to be an expert; just listen to whether what you hear makes sense.
That's it. None of these are exotic - they're the same dozen things, over and over, which is exactly why a checklist beats good intentions.
Comments
No comments yet. Start the discussion.