From fastlane-ui-design
Implement WCAG 2.2 compliant Angular interfaces with Angular CDK a11y patterns, Pangea-aware contrast checking, and Angular Material accessibility primitives. Use when auditing accessibility, implementing ARIA in Angular templates, building keyboard-navigable components, or ensuring Fastlane meets WCAG 2.2 AA.
How this skill is triggered — by the user, by Claude, or both
Slash command
/fastlane-ui-design:accessibility-complianceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Master accessibility implementation to create inclusive experiences for all users, with a focus on Angular and desktop-first patterns.
Master accessibility implementation to create inclusive experiences for all users, with a focus on Angular and desktop-first patterns.
| Level | Criterion | Description |
|---|---|---|
| A | 1.1.1 | Non-text content has text alternatives |
| A | 1.3.1 | Info and relationships programmatically determinable |
| A | 2.1.1 | All functionality keyboard accessible |
| A | 2.4.1 | Skip to main content mechanism |
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
| AA | 1.4.11 | Non-text contrast 3:1 |
| AA | 2.4.7 | Focus visible |
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
| AAA | 1.4.6 | Enhanced contrast 7:1 |
| AAA | 2.5.5 | Target size minimum 44x44px |
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-accessible-button',
standalone: true,
imports: [MatButtonModule, CommonModule],
template: `
<button
mat-raised-button
[color]="variant()"
[disabled]="disabled() || isLoading()"
[attr.aria-busy]="isLoading()"
[attr.aria-disabled]="disabled() || isLoading()"
(click)="handleClick()"
class="accessible-button"
>
@if (isLoading()) {
<span class="sr-only">Loading</span>
<mat-spinner diameter="20" />
} @else {
<ng-content />
}
</button>
`,
styleUrl: './accessible-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccessibleButtonComponent {
readonly variant = input<'primary' | 'secondary'>('primary');
readonly disabled = input<boolean>(false);
readonly isLoading = input<boolean>(false);
readonly clicked = output<void>();
handleClick(): void {
this.clicked.emit();
}
}
SCSS:
:host {
display: inline-block;
min-height: 44px;
min-width: 44px;
}
.accessible-button {
// Visible focus ring
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
import {
Component,
input,
output,
effect,
ChangeDetectionStrategy,
inject,
ViewChild,
ElementRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { A11yModule } from '@angular/cdk/a11y';
@Component({
selector: 'app-accessible-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
A11yModule,
],
template: `
<div
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="titleId"
[attr.aria-describedby]="descriptionId"
cdkTrapFocus
cdkTrapFocusAutoCapture
class="dialog-container"
>
<h2 [id]="titleId" class="dialog-title">{{ title() }}</h2>
<div [id]="descriptionId" class="dialog-content">
<ng-content />
</div>
<div class="dialog-actions">
<button mat-button (click)="close()">Close</button>
<button mat-raised-button color="primary" (click)="confirm()">
Confirm
</button>
</div>
<button
mat-icon-button
aria-label="Close dialog"
(click)="close()"
class="dialog-close"
>
<mat-icon>close</mat-icon>
</button>
</div>
`,
styleUrl: './accessible-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccessibleDialogComponent {
readonly title = input<string>('');
readonly closed = output<void>();
readonly confirmed = output<void>();
readonly titleId = `dialog-title-${Math.random().toString(36).slice(2)}`;
readonly descriptionId = `dialog-desc-${Math.random().toString(36).slice(2)}`;
close(): void {
this.closed.emit();
}
confirm(): void {
this.confirmed.emit();
}
}
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
<!-- Error summary for screen readers -->
@if (errors().length > 0) {
<div
id="form-errors"
role="alert"
aria-live="assertive"
aria-atomic="true"
class="error-summary"
>
<h2 class="error-summary__title">Please fix the following errors:</h2>
<ul class="error-summary__list">
@for (error of errors(); track error.field) {
<li>
<a [href]="'#' + error.field">{{ error.message }}</a>
</li>
}
</ul>
</div>
}
<!-- Email field with error -->
<div class="form-field">
<label for="email" class="form-label">
Email address
<span aria-hidden="true" class="required-indicator">*</span>
<span class="sr-only">(required)</span>
</label>
<input
id="email"
formControlName="email"
type="email"
required
[attr.aria-required]="true"
[attr.aria-invalid]="hasError('email')"
[attr.aria-describedby]="hasError('email') ? 'email-error' : 'email-hint'"
class="form-input"
/>
@if (hasError('email')) {
<p id="email-error" class="error-message" role="alert">
{{ getErrorMessage('email') }}
</p>
} @else {
<p id="email-hint" class="form-hint">We'll never share your email.</p>
}
</div>
<button type="submit" mat-raised-button color="primary">Submit</button>
</form>
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-skip-link',
standalone: true,
imports: [CommonModule],
template: `
<a
href="#main-content"
class="skip-link"
>
Skip to main content
</a>
`,
styleUrl: './skip-link.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SkipLinkComponent {}
SCSS:
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-primary);
color: white;
text-decoration: none;
border-radius: var(--radius-md);
&:focus {
left: var(--spacing-sm);
top: var(--spacing-sm);
}
}
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-search-results',
standalone: true,
imports: [CommonModule],
template: `
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
@if (message()) {
{{ message() }}
}
</div>
@if (isLoading()) {
<mat-progress-bar mode="indeterminate" />
} @else if (results().length === 0) {
<p>No results found.</p>
} @else {
<ul>
@for (result of results(); track result.id) {
<li>{{ result.title }}</li>
}
</ul>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchResultsComponent {
private readonly liveAnnouncer = inject(LiveAnnouncer);
readonly results = signal<SearchResult[]>([]);
readonly isLoading = signal(false);
readonly message = signal<string>('');
async onSearch(query: string): Promise<void> {
this.isLoading.set(true);
this.message.set('');
try {
const results = await this.searchService.search(query).toPromise();
this.results.set(results ?? []);
const count = results?.length ?? 0;
const msg = `${count} results found`;
this.message.set(msg);
await this.liveAnnouncer.announce(msg, 'polite');
} finally {
this.isLoading.set(false);
}
}
}
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
@Component({
selector: 'app-modal',
standalone: true,
imports: [A11yModule],
template: `
<div cdkTrapFocus cdkTrapFocusAutoCapture role="dialog">
<!-- Focus stays inside this element -->
<ng-content />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalComponent {}
import {
Component,
viewChildren,
AfterViewInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { FocusKeyManager } from '@angular/cdk/a11y';
@Component({
selector: 'app-campaign-list',
standalone: true,
template: `
<div role="listbox" (keydown)="onKeydown($event)">
@for (campaign of campaigns(); track campaign.id) {
<app-campaign-row [campaign]="campaign" />
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CampaignListComponent implements AfterViewInit {
@viewChildren(CampaignRowComponent) rows!: QueryList<CampaignRowComponent>;
private keyManager!: FocusKeyManager<CampaignRowComponent>;
ngAfterViewInit(): void {
this.keyManager = new FocusKeyManager(this.rows).withWrap();
}
onKeydown(event: KeyboardEvent): void {
this.keyManager.onKeydown(event);
}
}
Angular Material handles most ARIA automatically:
mat-form-field wires aria-labelledby between mat-label and the inputmat-dialog manages focus trap and role="dialog"mat-menu manages role="menu", role="menuitem", and arrow-key navigationmat-select manages role="listbox" and option selectionmat-table provides keyboard navigation with proper ARIATrust these — don't add redundant ARIA.
// Contrast ratio utilities
function getContrastRatio(foreground: string, background: string): number {
const getLuminance = (hex: string) => {
const rgb = hexToRgb(hex);
const [r, g, b] = rgb.map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const l1 = getLuminance(foreground);
const l2 = getLuminance(background);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// WCAG requirements
const CONTRAST_REQUIREMENTS = {
// Normal text (<18pt or <14pt bold)
normalText: {
AA: 4.5,
AAA: 7,
},
// Large text (>=18pt or >=14pt bold)
largeText: {
AA: 3,
AAA: 4.5,
},
// UI components and graphics
uiComponents: {
AA: 3,
},
};
When checking color contrast in Fastlane, report token-level fixes, not hex-level fixes:
Good: "Replace --pangea-text-tertiary on --surface (insufficient contrast at 3.5:1); use --pangea-text-secondary instead"
Bad: "Change hex #949494 to #4b5563"
This ensures fixes are maintainable and themed correctly.
// Component-level (Jest + jest-axe)
import { render } from '@testing-library/angular';
import { axe, toHaveNoViolations } from 'jest-axe';
describe('MyComponent', () => {
it('should have no accessibility violations', async () => {
const { container } = await render(MyComponent, {
providers: [provideAnimations()],
});
expect(await axe(container)).toHaveNoViolations();
});
});
// E2E level (Playwright + @axe-core/playwright)
import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
test('page should have no accessibility violations', async ({ page }) => {
await page.goto('https://fastlane.local/campaigns');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
prefers-reduced-motion and prefers-contrastweb-component-design — Angular component patterns with a11y in mindresponsive-design — desktop-first patterns with touch targetsvisual-design-foundations — contrast and color token usagenpx claudepluginhub pat-richardson/fastlane-ui-design --plugin fastlane-ui-designGuides collaborative design exploration before implementation: explores context, asks clarifying questions, proposes approaches, and writes a design doc for user approval.
Creates structured, bite-sized implementation plans from specs or requirements before writing code. Useful for breaking down multi-step tasks into testable steps with file structure and task boundaries.
Implements work from a spec or tickets using TDD at agreed seams, with regular typechecking and test runs, followed by code review.