Angular Accessibility (a11y)
When To Activate
- After any development touching Angular templates, including a new feature, bug fix, or refactor
- After updating Angular Material markup, control structure, labels, dialogs, tables, forms, or navigation UI
- Fixing Lighthouse, axe, or ESLint accessibility findings
- Auditing Angular templates or Angular Material screens
- Debugging keyboard navigation, focus order, or screen reader output
- Refactoring interactive UI in Angular HTML templates
Default Post-Development Trigger
Run this skill as a final verification step whenever the implementation modified .html templates or materially changed rendered UI structure, even if the original task did not mention accessibility.
Typical triggers:
- New feature that introduces or changes template markup
- Bug fix that changes form controls, buttons, tables, dialogs, drawers, or navigation
- Refactor that restructures conditionals, loops, landmarks, headings, or interactive elements
- Angular Material component replacement or reconfiguration affecting labels, focus, or keyboard behavior
Hard Rules
- Prefer semantic HTML first: native
<button>, <a>, <label>, <main>, <table>, and <th> before ARIA workarounds
- Prefer visible labels,
<label for>, <mat-label>, or aria-labelledby before aria-label
- Use ARIA to fill missing semantics, not to replace native semantics
- For native elements, bind ARIA with
[attr.aria-*]
- For Angular Material components that expose dedicated inputs, prefer
[aria-label] or [aria-labelledby] without attr.
- Do not add redundant roles to native controls (
role="button" on <button>, role="checkbox" on <input type="checkbox">, etc.)
- All user-facing accessible text must be translated with Transloco. Never hard-code text in templates
- After each fix, verify keyboard and focus behavior, not just lint output
Audit Workflow
- Run the ESLint accessibility check first:
npx eslint "src/app/<feature>/**/*.html"
- Run an automated audit on the affected screen:
- Lighthouse
- axe DevTools or equivalent
- Perform a keyboard-only smoke test:
Tab / Shift+Tab reaches all interactive controls in a logical order
Enter / Space activates buttons, toggles, and checkboxes
Escape closes dialogs, menus, or drawers where applicable
- Focus remains visible throughout the flow
- Spot-check the changed flow with a screen reader
- Fix issues systematically using the patterns below
- Re-run lint and the automated audit on the affected screen
Common Issues & Fixes
1. Icon-only buttons - missing accessible name
Any <button> whose only content is a <mat-icon> or similar icon has no accessible name.
Fix: add a translated accessible name and mark the icon as decorative.
<!-- ❌ Before -->
<button type="button" (click)="onClose()">
<mat-icon>close</mat-icon>
</button>
<!-- ✅ After -->
<button
type="button"
[attr.aria-label]="t('scope.key.close-btn-aria')"
(click)="onClose()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
Same pattern applies to pagination, toggle, toolbar, and other icon-only actions.
2. Form controls - missing programmatic label
Prefer a visible label first. Use aria-label only when a visible label or aria-labelledby is not practical.
Standard forms: use <label for> or <mat-label>.
<!-- ✅ Preferred in a regular form -->
<mat-form-field>
<mat-label>{{ t('scope.key.amount') }}</mat-label>
<input matInput id="amount" type="number" />
</mat-form-field>
Dense table/grid cells: use row-level context so each control is uniquely identifiable.
<!-- ❌ Before -->
<input matInput type="number" [value]="row.amount" />
<mat-select [value]="row.type"></mat-select>
<!-- ✅ After -->
<input
matInput
type="number"
[value]="row.amount"
[attr.aria-label]="t('scope.key.amount-aria', { invoiceNumber: row.invoiceNumber })"
/>
<mat-select
[value]="row.type"
[aria-label]="t('scope.key.type-aria', { invoiceNumber: row.invoiceNumber })"
>
</mat-select>
3. Checkboxes without labels
Empty mat-checkbox components with no projected text need an accessible name.
<!-- ❌ Before -->
<mat-checkbox
[checked]="allSelected()"
(change)="onToggleAll($event.checked)"
/>
<!-- ✅ After - header (select-all) -->
<mat-checkbox
[aria-label]="t('scope.key.select-all-aria')"
[checked]="allSelected()"
(change)="onToggleAll($event.checked)"
/>
<!-- ✅ After - row (select one) -->
<mat-checkbox
[aria-label]="t('scope.key.select-row-aria', { invoiceNumber: row.invoiceNumber })"
[checked]="row.selected"
(change)="onToggleItem(row.id, $event.checked)"
/>
4. Non-semantic interactive elements
Clickable <div> and <span> elements break keyboard and assistive technology behavior.
Fix: replace them with native interactive elements whenever possible.
<!-- ❌ Before -->
<div class="close-action" (click)="onClose()">
<mat-icon>close</mat-icon>
</div>
<!-- ✅ After -->
<button
type="button"
class="close-action"
[attr.aria-label]="t('scope.key.close-btn-aria')"
(click)="onClose()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
Use <a> for navigation and <button> for in-page actions.
5. Viewport blocks zoom
maximum-scale or user-scalable=no in the viewport meta tag prevents low-vision users from magnifying the page.
Fix (src/index.html): keep only width=device-width, initial-scale=1.0.
<!-- ❌ Before -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<!-- ✅ After -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6. Missing <main> landmark
Screen readers use landmarks to jump between page regions. A page must have exactly one <main> element.
In this project the layout can render two conditional branches (mobile / desktop). Each branch should expose its content wrapper as <main>. Since they are mutually exclusive, only one <main> is in the DOM at a time.
<!-- mobile: main.component.html -->
<!-- ❌ Before: <div class="main-container"> -->
<main class="main-container" cdkVirtualScrollingElement>
<gc-sub-header />
<router-outlet />
</main>
<!-- desktop: page.component.html -->
<!-- ❌ Before: <div class="desktop-page-content"> -->
<main class="desktop-page-content">
<router-outlet />
</main>
SCSS that targets .main-container or .desktop-page-content keeps working when only the element tag changes.
7. Spinners and loading states
mat-progress-spinner already follows the ARIA progressbar pattern. Do not replace it with role="status".
Fix:
- keep the component's default semantics
- add an accessible name if the spinner would otherwise be ambiguous
- use a nearby
aria-live="polite" message only when the loading state itself must be announced
<!-- ✅ Spinner keeps its default progressbar semantics -->
<mat-progress-spinner
[attr.aria-label]="t('shared.loading-aria')"
mode="indeterminate"
diameter="32"
/>
<!-- ✅ Optional live region when the loading state should be announced -->
@if (isLoading()) {
<span aria-live="polite">{{ t('shared.loading-aria') }}</span>
}
8. Dialogs, drawers, and disclosure controls
Interactive overlays and expandable sections must expose state and preserve focus behavior.
Verify:
- the trigger has an accessible name
- expandable controls expose
aria-expanded when they show or hide related content
- dialogs have a visible title and, when needed, a description
- focus moves into the dialog when opened and returns to the trigger when closed
<button
type="button"
aria-controls="filters-panel"
[attr.aria-expanded]="isFiltersOpen()"
(click)="toggleFilters()"
>
{{ t('scope.key.filters') }}
</button>
@if (isFiltersOpen()) {
<section id="filters-panel">...</section>
}
For Angular Material dialogs, keep the built-in focus management unless there is a concrete need to change it.
Translation Convention
All accessible names and descriptions must use translated strings via Transloco. Never hard-code French or any other language directly in templates.
- In templates, follow the existing
t('scope.key') pattern
- Key naming: append
-aria to accessible-name keys and -description when the text is used for extra context
- Add keys to the feature's scoped
fr-FR.json
- Use contextual params when the label must uniquely identify a row or control
Example JSON additions:
{
"table": {
"select-all-aria": "Sélectionner toutes les échéances",
"select-row-aria": "Sélectionner l'échéance {{invoiceNumber}}",
"discount-amount-aria": "Montant escompte pour la facture {{invoiceNumber}}"
},
"pagination": {
"previous-aria": "Page précédente",
"next-aria": "Page suivante"
},
"selection-detail": {
"close-btn-aria": "Fermer le volet de détails"
}
}
Validation Checklist
After fixing, verify:
Known Limits
- This skill covers common template and component-level accessibility issues, not full WCAG certification
- Color contrast, timing, motion, content wording, and design-system token changes may require separate design or product decisions
- Complex screen reader behavior should be validated on the real user flow, not assumed from lint output alone