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 usageCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub pat-richardson/fastlane-ui-design --plugin fastlane-ui-design