name: angular-accessibility description: Audit and fix common accessibility issues in Angular templates and Angular Material components. Use when the user mentions Lighthouse, axe, screen readers, keyboard navigation, ARIA, asks to fix a11y issues in Angular HTML templates, or after any feature, bug fix, or refactor that changed Angular templates.
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>, oraria-labelledbybeforearia-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]withoutattr. - 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+Tabreaches all interactive controls in a logical orderEnter/Spaceactivates buttons, toggles, and checkboxesEscapecloses 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-containeror.desktop-page-contentkeeps 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-expandedwhen 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
-ariato accessible-name keys and-descriptionwhen 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:
- Viewport meta tag has no
maximum-scaleoruser-scalable=no - Page has exactly one
<main>landmark - All icon-only buttons have an accessible name
- Decorative icons inside labelled controls use
aria-hidden="true" - Form controls have a visible label,
aria-labelledby, oraria-labelas appropriate - Empty
mat-checkboxcomponents have[aria-label]or[aria-labelledby] - Clickable
divandspanelements were replaced with semantic controls - Expand/collapse controls expose
aria-expandedwhen applicable - Dialogs and drawers keep correct focus entry and focus return behavior
- Loading indicators keep correct semantics and use a live region only when announcement is needed
- Informative images have meaningful
alt; decorative images use emptyalt="" - Keyboard-only navigation works end-to-end and focus stays visible
- All accessible text uses translated strings
- ESLint passes:
npx eslint "src/app/<feature>/**/*.html" - Automated audit was re-run on the affected screen
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