I've been writing Angular for ten years, and for most of them I treated accessibility the way nearly everyone does: as a thing I'd "get to" once the feature worked. It always lost the race to the deadline.
What changed my mind wasn't guilt β it was realising why a11y keeps getting skipped. It's not neglect. It's that accessibility is genuinely hard to hold in your head while you're also wrangling state, layout, and a ticket that was due yesterday. You can't fix "I forgot" with willpower. You fix it with a checklist.
So here's the one I actually use before a component ships. It's WCAG AA-oriented, Angular-specific, and ordered the way bugs actually show up. One thing up front, because it's the whole reason a list like this exists:
Automated tools catch maybe 30β40% of WCAG issues. AXE, Lighthouse and the rest are brilliant at the mechanical stuff β a missing
alt, a contrast ratio, a duplicateid. They cannot tell you whether your tab order makes sense, whether your focus goes somewhere useful after a dialog closes, or whether youraria-liveregion actually announces anything. A green AXE run is the floor, not the finish line. I've marked each item below with whether a tool will catch it (π€) or whether it's on you (π€).
1. 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.
2. 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>. π€/π€
3. 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
hostobject, 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.
4. 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). π€
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. π€
6. 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(or jest-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.
I got tired of running this by hand for every component, so I built it into the defaults. NgBracket is a set of Angular component packs where every one of these is handled out of the box β keyboard, focus management, ARIA, contrast, the screen-reader table behind every chart β so AA-by-default is the starting point, not a ticket for later. If accessibility is the part of your build that keeps losing the race to the deadline, that's exactly the problem it's meant to take off your plate. We're launching soon; join the waiting list for early access.
Top comments (0)