DEV Community

Cover image for The WCAG AA Checklist Every Angular Component Should Pass Before You Ship
Duncan Faulkner
Duncan Faulkner

Posted on • Originally published at ngbracket.com

The WCAG AA Checklist Every Angular Component Should Pass Before You Ship

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 duplicate id. They cannot tell you whether your tab order makes sense, whether your focus goes somewhere useful after a dialog closes, or whether your aria-live region 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 adding role="button" and a keydown handler to a div, stop and use a button.

  • Focus is always visible. πŸ€– Never outline: none without a replacement. Use :focus-visible so keyboard users get a clear ring and mouse users aren't bothered:

  :focus-visible {
    outline: 2px solid var(--ngbr-color-focus);
    outline-offset: 2px;
  }
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode
  • 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"
    });
Enter fullscreen mode Exit fullscreen mode
  • 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 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) … */ }
Enter fullscreen mode Exit fullscreen mode
  • Icon-only buttons have an accessible name. πŸ€– aria-label="Close" β€” an icon font or SVG is silent otherwise.

  • No aria-* attribute references a missing id. πŸ€– Dangling aria-describedby/aria-labelledby announce 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 matching for/id β€” not just a placeholder. πŸ€–

  • Required fields expose it programmatically: [required] or aria-required="true", not just an asterisk. πŸ€–/πŸ‘€

  • Errors are linked to their field and announced. πŸ‘€ Tie the message in with aria-describedby, flip aria-invalid, and give it role="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>
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
    }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 LiveAnnouncer rather than hand-rolling a live region:
  private announcer = inject(LiveAnnouncer);
  // after a filter runs:
  this.announcer.announce(`${count} results`, 'polite');
Enter fullscreen mode Exit fullscreen mode
  • Loading and busy states are exposed, e.g. aria-busy="true" on the region being updated. πŸ‘€

  • Images have meaningful alt (and decorative ones have alt=""). πŸ€– With NgOptimizedImage, alt is 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

  1. 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.
  2. Keyboard pass β€” unplug the mouse, Tab through the whole flow. Most πŸ‘€ issues surface in 60 seconds.
  3. 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)