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

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 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;
    }
    
  • 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 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.

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>
    }
    
  • 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 LiveAnnouncer rather 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 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

  • 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.

Comments

No comments yet. Start the discussion.