This is the checklist I run before an Angular component ships. It covers WCAG Level AA, groups the items by area, and includes Angular code for the common fixes.
I've written Angular for ten years, and for most of them accessibility was something I'd do after the feature worked. It usually didn't get done. A written checklist made it part of finishing a component.
Automated tools such as axe and Lighthouse check roughly a third of WCAG. They find missing alt text, low text contrast and duplicate ids. They can't tell whether the tab order makes sense, where focus goes after a dialog closes, or whether a live region is announced. Each item below ends with one of these labels:
- Automated: axe or a similar tool checks it.
- Partly automated: a tool catches some cases, and you check the rest.
- Manual: you have to test it yourself.
1. Keyboard and focus
Everything interactive works with the keyboard alone. Put the mouse away and use the component with Tab, Enter, Space and the arrow keys. Manual.
Native elements are used where possible. A
<button>is focusable, works with the keyboard and is announced as a button. A<div (click)>is none of those. If you're addingrole="button"and akeydownhandler to adiv, use abuttoninstead. Partly automated.Focus is always visible. Don't set
outline: nonewithout a replacement. Use:focus-visibleso keyboard users see a ring and mouse users don't. The ring needs 3:1 contrast against the background. Manual.
:focus-visible {
outline: 2px solid #1a56db; /* your focus colour, at least 3:1 against the background */
outline-offset: 2px;
}
Tab order follows reading order. Don't use a positive
tabindex. If the visual order and the DOM order differ, change the DOM. Partly automated.Modal dialogs trap focus and return it to the trigger when they close. The CDK
Dialogservice does both. If you build your own dialog,cdkTrapFocushandles the trap, and you have to return focus to the trigger yourself. Manual.
<div cdkTrapFocus cdkTrapFocusAutoCapture role="dialog" aria-modal="true" aria-labelledby="title">
<h2 id="title">Confirm deletion</h2>
…
</div>
- Nothing else traps the keyboard. You can always Tab out of a component. An open modal is the only exception. Manual.
2. Focus after something changes
Tools can't check most of this section.
- Route changes move focus. After navigation in a single-page app, focus stays on an element that no longer exists. Move it to the new page's heading. Manual.
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 or dialog moves focus into it, and closing it returns focus to the trigger. Manual.
Deleting an item moves focus to a sensible place, such as the next row or the list heading. Focus shouldn't fall back to
<body>. Manual.A skip link lets keyboard users jump past the navigation to
<main>. Partly automated.
3. Semantics and ARIA
Use a native element when one exists. Add ARIA only when it doesn't.
The page has landmarks: one
<main>, plus<nav>,<header>and<footer>where they apply. Partly automated.Each page has one
<h1>, and heading levels don't skip. Screen-reader users move around a page by its headings. Partly automated.Custom widgets have the right role and state. Set them in the
hostobject rather than with@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) … */ }
Partly automated: axe checks that the role and attributes are valid, but not that the state changes when it should.
Icon-only buttons have an accessible name, such as
aria-label="Close". An icon font or SVG on its own has no name. Automated.aria-describedbyandaria-labelledbypoint atids that exist. A missingidmeans nothing is announced. Partly automated: axe reports a missingidas needing review, so a CI check that fails only on violations won't catch it.Native elements don't repeat their own role.
<button role="button">and<nav role="navigation">add nothing. Manual.
4. Forms and errors
Every input has a
<label>with a matchingforandid. A placeholder isn't a label. Partly automated: axe flags inputs with no name at all, but accepts a placeholder as a name.Required fields are marked in code with
requiredoraria-required="true", as well as visually. Manual.Errors are linked to their field and announced. Connect the message with
aria-describedby, setaria-invalid, and give the messagerole="alert"so it's read when it appears. Manual.
<label for="email">Email</label>
<input id="email" type="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 to an error summary, so keyboard users don't have to search for it. Manual.
Errors are shown with text or an icon as well as colour (see section 5). Manual.
5. Colour, contrast and motion
Text meets AA contrast: 4.5:1 for body text and 3:1 for large text. Partly automated: axe can't work out contrast over translucent layers, gradients or images.
Control borders, icons and focus indicators meet 3:1 against what's behind them (WCAG 1.4.11). axe doesn't check this. Manual.
Colour isn't the only way information is shown (WCAG 1.4.1). A red border needs an icon or text too. Charts and status indicators are where this fails most often. Manual.
The UI works at 200% zoom, and reflows at 400% without horizontal scrolling or clipped content. Manual.
Animation respects
prefers-reduced-motion. Motion can trigger vestibular disorders. Manual.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
- Contrast holds in light and dark themes. Take colours from theme tokens rather than hard-coding them, and run the contrast check in both themes. Partly automated.
6. Dynamic content
- Updates that happen without a page load are announced: "Showing 12 results", "Saved", "Loading". Use the CDK's
LiveAnnouncerrather than writing your own live region. Manual.
private announcer = inject(LiveAnnouncer);
// after a filter runs:
this.announcer.announce(`${count} results`, 'polite');
Loading and busy states are exposed, for example with
aria-busy="true"on the region that is updating. Manual.Images have useful
alttext, and decorative images havealt="". Automated for missingalt, manual for whether the text is useful.Charts have a text version. An SVG chart gives a screen reader nothing to read, so render the same data as a visually hidden
<table>. No tool flags a chart without one. Manual.
How to run it
- Automated pass. Add
@axe-core/playwrightorjest-axeto CI so the automated items can't regress. - Keyboard pass. Put the mouse away and Tab through the whole flow. Most manual items show up within a minute.
- Screen-reader pass. Use VoiceOver (Cmd+F5 on macOS) or NVDA (free on Windows). Listen for whether what you hear matches what's on screen.
Most components fail on the same dozen items, which is why a written checklist helps.
NgBracket is a set of Angular component packs built to support WCAG 2.2 AA, with keyboard support, focus management, ARIA and chart data tables built in. The conformance report lists how each criterion is tested and the known limitations. You can start with the free packs without a card.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more