
Frontend Accessibility
- 1 installs
- 3 repo stars
- Updated July 8, 2026
- ai-enhanced-engineer/aiee-team
frontend-accessibility is a Claude Code skill providing WCAG 2.1 AA accessibility patterns for web UIs, covering ARIA, keyboard navigation, screen readers, and contrast.
About
frontend-accessibility is a Claude Code skill covering web accessibility patterns for WCAG 2.1 AA compliance. It provides quick references for ARIA landmarks, keyboard navigation, color contrast, focus management, and common mistakes, plus an interactive-component checklist. A developer uses it during accessibility audits or when implementing accessible, framework-agnostic and Angular components.
- WCAG 2.1 AA accessibility patterns: ARIA, keyboard nav, screen readers, contrast
- Includes a contrast-fix color table and an interactive-component a11y checklist
- Shows native <details> progressive disclosure and Angular accessible-component patterns
Frontend Accessibility by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,912 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 9, 2026 (Skillselion catalog sync)
frontend-accessibility capabilities & compatibility
- Capabilities
- accessibility audit · aria implementation · keyboard navigation
- Use cases
- frontend · ui design
What frontend-accessibility says it does
Web accessibility patterns for WCAG 2.1 AA compliance including ARIA, keyboard navigation, screen reader support, and Angular and framework-agnostic implementations.
Normal text: **4.5:1** minimum
npx skills add https://github.com/ai-enhanced-engineer/aiee-team --skill frontend-accessibilityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 3 |
| Last updated | July 8, 2026 |
| Repository | ai-enhanced-engineer/aiee-team ↗ |
What it does
Implement or audit WCAG 2.1 AA accessible web UI using ARIA, keyboard navigation, contrast, and focus-management patterns.
Who is it for?
Accessibility audits and implementing WCAG 2.1 AA compliant, inclusive web components.
Skip if: Backend work or non-accessibility frontend architecture decisions.
When should I use this skill?
For accessibility audits, a11y implementation, or inclusive design.
What you get
Accessible components with correct ARIA, keyboard support, contrast, and focus management.
By the numbers
- Contrast targets: 4.5:1 normal text, 3:1 large text and interactive elements
- 5-item interactive-component accessibility checklist
Files
Frontend Accessibility Patterns
WCAG 2.1 AA compliance patterns for modern web applications.
Core Principles (POUR)
| Principle | Description |
|---|---|
| Perceivable | Information presentable in ways users can perceive |
| Operable | Interface components operable by all users |
| Understandable | Information and operation understandable |
| Robust | Works with current and future technologies |
Quick Reference
Keyboard Navigation
- Tab/Shift+Tab: Navigate interactive elements
- Enter/Space: Activate buttons/links
- Escape: Close modals/dropdowns
- Arrow keys: Navigate lists/menus
Color Contrast (WCAG AA)
- Normal text: 4.5:1 minimum
- Large text (18pt+): 3:1 minimum
- Interactive elements: 3:1 minimum
Common Adjustments (maintain hue, reduce HSL lightness ~20%):
| Original | Fixed | Use Case | Contrast |
|---|---|---|---|
#ef4444 | #c53030 | Error red | 4.5:1 ✅ |
#f59e0b | #b45309 | Warning orange | 4.5:1 ✅ |
#10b981 | #047857 | Success green | 4.5:1 ✅ |
#3b82f6 | #1d4ed8 | Info blue | 4.5:1 ✅ |
Rule: For status colors on white background, check contrast at webaim.org/resources/contrastchecker
ARIA Essentials
<!-- Landmarks -->
<header role="banner">
<nav role="navigation" aria-label="Main">
<main role="main">
<footer role="contentinfo">
<!-- Live regions (for dynamic content like chat) -->
<div role="status" aria-live="polite" aria-atomic="true">
<!-- Screen reader announces changes -->
</div>
<!-- Accessible buttons -->
<button aria-label="Close dialog" aria-pressed="false">Focus Management
- Visible focus indicators (never
outline: nonewithout alternative) - Trap focus in modals
- Return focus on modal close
- Manage focus on route changes
Common Mistakes
- Missing
alton images - Form inputs without labels
- Color as only indicator
- Mouse-only interactions
- Missing skip links
- Auto-playing media
- Conflicting visual states (e.g., "featured" and "selected" both using same visual indicator—use distinct patterns)
Interactive Component Accessibility Checklist
For custom interactive components (tabs, accordions, selectors, toggles):
- [ ] ARIA markup - Correct role, aria-checked/selected/expanded
- [ ] JavaScript announcements - aria-live regions for state changes
- [ ] Keyboard navigation - Tab, Enter/Space, Arrow keys
- [ ] Visual states - Clear focus/selected/disabled indicators
- [ ] Focus management - Logical flow, no focus traps
Progressive Disclosure with Native HTML
Use native <details> elements instead of custom JavaScript accordions:
<details class="expandable-section">
<summary>
<h3>Section Title</h3>
<span class="chevron" aria-hidden="true">▼</span>
</summary>
<div class="expanded-content">
<!-- Content here -->
</div>
</details>details summary {
cursor: pointer;
list-style: none;
user-select: none;
}
details summary::-webkit-details-marker {
display: none;
}
details[open] .chevron {
transform: rotate(180deg);
}
details summary:focus {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}Benefits:
- Built-in keyboard navigation (Tab, Enter/Space to toggle)
- Screen readers announce "collapsed/expanded" state automatically
- Works without JavaScript
- Less code to maintain
When to use: FAQs, expandable cards, skill lists, workflow phases, any progressive disclosure pattern
Decorative Elements with Text Alternatives
For visual flow indicators (arrows, connectors):
<!-- Visual arrows (hidden from screen readers) -->
<div class="flow-arrow" aria-hidden="true">...</div>
<!-- Text alternative for screen readers and mobile -->
<p class="flow-description">
After Phase 5, teams iterate back to Phase 1 (max 3 cycles).
</p>Responsive strategy:
- Desktop (1024px+): Show visual arrows, hide text description
- Mobile/tablet: Hide arrows, show text description
See reference.md for WCAG criteria and examples.md for Angular implementations.
Frontend Accessibility Examples
Angular 21+ accessible component patterns.
Accessible Button
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-accessible-button',
standalone: true,
template: `
<button
(click)="clicked.emit()"
[disabled]="disabled() || loading()"
[attr.aria-label]="label()"
[attr.aria-busy]="loading()"
[attr.aria-disabled]="disabled()">
@if (loading()) {
<span class="sr-only">Loading...</span>
<span aria-hidden="true" class="spinner"></span>
} @else {
<ng-content />
}
</button>
`,
styles: [`
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`]
})
export class AccessibleButtonComponent {
label = input.required<string>();
disabled = input(false);
loading = input(false);
clicked = output<void>();
}Accessible Modal
import { Component, input, output, effect, viewChild, ElementRef, signal } from '@angular/core';
@Component({
selector: 'app-accessible-modal',
standalone: true,
template: `
<dialog
#dialogRef
(keydown.escape)="handleEscape($event)"
(click)="handleBackdropClick($event)"
aria-labelledby="dialog-title"
aria-modal="true">
<header>
<h2 id="dialog-title">{{ title() }}</h2>
<button
(click)="closed.emit()"
aria-label="Close dialog"
class="close-btn">
<span aria-hidden="true">×</span>
</button>
</header>
<div class="content">
<ng-content />
</div>
</dialog>
`,
styles: [`
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
dialog {
border: none;
border-radius: 8px;
padding: 0;
max-width: 500px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
}
.close-btn:focus-visible {
outline: 2px solid #007bff;
outline-offset: 2px;
}
`]
})
export class AccessibleModalComponent {
title = input.required<string>();
open = input(false);
closed = output<void>();
private dialogRef = viewChild.required<ElementRef<HTMLDialogElement>>('dialogRef');
private previouslyFocused: HTMLElement | null = null;
constructor() {
effect(() => {
const dialog = this.dialogRef().nativeElement;
if (this.open()) {
this.previouslyFocused = document.activeElement as HTMLElement;
dialog.showModal();
} else {
dialog.close();
this.previouslyFocused?.focus();
}
});
}
handleEscape(e: KeyboardEvent): void {
e.preventDefault();
this.closed.emit();
}
handleBackdropClick(e: MouseEvent): void {
if (e.target === this.dialogRef().nativeElement) {
this.closed.emit();
}
}
}Accessible Form Input
import { Component, input, computed, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-accessible-input',
standalone: true,
imports: [FormsModule],
template: `
<div class="field" [class.has-error]="error()">
<label [for]="id()">
{{ label() }}
@if (required()) {
<span aria-hidden="true" class="required">*</span>
<span class="sr-only">(required)</span>
}
</label>
<input
[id]="id()"
[type]="type()"
[(ngModel)]="value"
[required]="required()"
[attr.aria-invalid]="error() ? 'true' : null"
[attr.aria-describedby]="describedBy()" />
@if (hint() && !error()) {
<span [id]="id() + '-hint'" class="hint">{{ hint() }}</span>
}
@if (error()) {
<span [id]="id() + '-error'" class="error" role="alert">
{{ error() }}
</span>
}
</div>
`,
styles: [`
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.required { color: #dc3545; }
.has-error input { border-color: #dc3545; }
.error { color: #dc3545; font-size: 0.875rem; }
.hint { color: #6c757d; font-size: 0.875rem; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`]
})
export class AccessibleInputComponent {
id = input.required<string>();
label = input.required<string>();
type = input('text');
value = model('');
error = input<string>();
hint = input<string>();
required = input(false);
describedBy = computed(() => {
const parts: string[] = [];
if (this.error()) parts.push(`${this.id()}-error`);
if (this.hint()) parts.push(`${this.id()}-hint`);
return parts.length > 0 ? parts.join(' ') : null;
});
}Skip Link
import { Component, input } from '@angular/core';
@Component({
selector: 'app-skip-link',
standalone: true,
template: `
<a [href]="'#' + targetId()" class="skip-link">
{{ label() }}
</a>
`,
styles: [`
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 0.5rem 1rem;
background: #007bff;
color: white;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
`]
})
export class SkipLinkComponent {
targetId = input.required<string>();
label = input('Skip to main content');
}Focus Trap Utility
// utils/focus-trap.ts
export function createFocusTrap(container: HTMLElement) {
const focusableSelectors = [
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
let previouslyFocused: HTMLElement | null = null;
function getFocusableElements(): HTMLElement[] {
return Array.from(container.querySelectorAll(focusableSelectors));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
return {
activate() {
previouslyFocused = document.activeElement as HTMLElement;
container.addEventListener('keydown', handleKeydown);
const focusable = getFocusableElements();
focusable[0]?.focus();
},
deactivate() {
container.removeEventListener('keydown', handleKeydown);
previouslyFocused?.focus();
}
};
}Screen Reader Only Utility Class
/* Include in global styles */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}Interactive Component Pattern
Complete example of accessible tier selection with radiogroup pattern:
<!-- Radiogroup pattern for selection -->
<div class="options" role="radiogroup" aria-label="Select your option">
<button type="button" class="option-btn" role="radio"
aria-checked="true" data-value="option1">Option 1</button>
<button type="button" class="option-btn" role="radio"
aria-checked="false" data-value="option2">Option 2</button>
<button type="button" class="option-btn" role="radio"
aria-checked="false" data-value="option3">Option 3</button>
</div>
<!-- Live region for state change announcements -->
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only"
id="selection-status"></div>// Handle selection
function selectOption(button) {
const value = button.dataset.value;
// Update ARIA states
document.querySelectorAll('.option-btn').forEach(btn => {
btn.setAttribute('aria-checked', 'false');
});
button.setAttribute('aria-checked', 'true');
// Announce to screen readers
const statusDiv = document.getElementById('selection-status');
statusDiv.textContent = `${value} selected`;
}
// Keyboard navigation
document.querySelector('.options').addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
focusNextRadio();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
focusPreviousRadio();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.target.click();
}
});/* Focus state (keyboard navigation) */
.option-btn:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Selected state */
.option-btn[aria-checked="true"] {
border: 2px solid var(--accent-color);
background: var(--accent-bg);
}Key Implementation Notes
1. ARIA markup: role="radiogroup", role="radio", aria-checked for proper semantics 2. Live region: aria-live="polite" announces state changes without interrupting 3. Keyboard support: Arrow keys navigate, Enter/Space activates 4. Distinct visual states: Selected uses border/background change 5. Focus management: focus-visible shows keyboard focus, logical tab order
Frontend Accessibility Reference
WCAG 2.1 Level AA Success Criteria
Perceivable
| Criterion | Requirement |
|---|---|
| 1.1.1 Non-text Content | All images, icons have text alternatives |
| 1.2.1-5 Time-based Media | Captions, audio descriptions for video |
| 1.3.1 Info and Relationships | Semantic HTML, proper headings hierarchy |
| 1.3.2 Meaningful Sequence | Reading order matches visual order |
| 1.3.3 Sensory Characteristics | Don't rely solely on shape, size, location |
| 1.3.4 Orientation | Support both portrait and landscape |
| 1.3.5 Identify Input Purpose | Use autocomplete for common fields |
| 1.4.1 Use of Color | Color not sole means of conveying info |
| 1.4.3 Contrast (Minimum) | 4.5:1 for text, 3:1 for large text |
| 1.4.4 Resize Text | 200% zoom without loss of content |
| 1.4.5 Images of Text | Use real text, not images of text |
| 1.4.10 Reflow | No horizontal scroll at 320px width |
| 1.4.11 Non-text Contrast | 3:1 for UI components and graphics |
| 1.4.12 Text Spacing | Content readable with increased spacing |
| 1.4.13 Content on Hover/Focus | Dismissible, hoverable, persistent |
Operable
| Criterion | Requirement |
|---|---|
| 2.1.1 Keyboard | All functionality keyboard accessible |
| 2.1.2 No Keyboard Trap | Focus can move away from any element |
| 2.1.4 Character Key Shortcuts | Single-key shortcuts can be disabled |
| 2.2.1 Timing Adjustable | Time limits can be extended |
| 2.2.2 Pause, Stop, Hide | Moving content controllable |
| 2.3.1 Three Flashes | No content flashes more than 3x/second |
| 2.4.1 Bypass Blocks | Skip navigation links available |
| 2.4.2 Page Titled | Pages have descriptive titles |
| 2.4.3 Focus Order | Logical, predictable focus sequence |
| 2.4.4 Link Purpose | Link text describes destination |
| 2.4.5 Multiple Ways | Multiple ways to find pages |
| 2.4.6 Headings and Labels | Descriptive headings and labels |
| 2.4.7 Focus Visible | Keyboard focus indicator visible |
Understandable
| Criterion | Requirement |
|---|---|
| 3.1.1 Language of Page | Page lang attribute set |
| 3.1.2 Language of Parts | Lang attribute on foreign text |
| 3.2.1 On Focus | No context change on focus |
| 3.2.2 On Input | No unexpected context change on input |
| 3.2.3 Consistent Navigation | Navigation consistent across pages |
| 3.2.4 Consistent Identification | Same function = same label |
| 3.3.1 Error Identification | Errors clearly identified and described |
| 3.3.2 Labels or Instructions | Form inputs have labels |
| 3.3.3 Error Suggestion | Suggest corrections for errors |
| 3.3.4 Error Prevention | Confirm, review, reversible for important actions |
Robust
| Criterion | Requirement |
|---|---|
| 4.1.1 Parsing | Valid HTML (no duplicate IDs) |
| 4.1.2 Name, Role, Value | Custom controls have accessible name/role |
| 4.1.3 Status Messages | Status updates announced without focus |
---
Testing Tools
Automated Testing
| Tool | Use For |
|---|---|
| axe DevTools | Browser extension, CI integration |
| Lighthouse | Chrome DevTools, general audit |
| WAVE | Browser extension, visual feedback |
| eslint-plugin-jsx-a11y | Linting for React/JSX (adaptable patterns) |
Manual Testing
| Tool | Use For |
|---|---|
| Keyboard only | Tab through entire page, test all interactions |
| Screen readers | VoiceOver (Mac), NVDA (Windows), Orca (Linux) |
| Color contrast checker | WebAIM contrast checker |
| Browser zoom | Test at 200% zoom |
Screen Reader Testing Guide
# VoiceOver (Mac)
Cmd + F5 # Toggle VoiceOver on/off
Ctrl + Option + → # Navigate forward
Ctrl + Option + ← # Navigate backward
Ctrl + Option + Space # Activate element
# NVDA (Windows)
Insert + Space # Toggle forms/browse mode
Tab # Navigate form controls
Arrow keys # Read content
Enter # Activate links/buttons---
ARIA Roles Reference
Landmark Roles
<header role="banner"> <!-- Page header, once per page -->
<nav role="navigation"> <!-- Navigation, use aria-label for multiple -->
<main role="main"> <!-- Main content, once per page -->
<aside role="complementary"> <!-- Sidebar, related content -->
<footer role="contentinfo"> <!-- Page footer, once per page -->
<form role="search"> <!-- Search form -->Widget Roles
<div role="dialog" aria-modal="true"> <!-- Modal dialog -->
<div role="alertdialog"> <!-- Alert requiring response -->
<div role="alert"> <!-- Important, time-sensitive -->
<div role="status"> <!-- Status update -->
<div role="tablist"> <!-- Tab container -->
<button role="tab" aria-selected="true"> <!-- Tab button -->
<div role="tabpanel"> <!-- Tab content -->
<ul role="listbox"> <!-- Selectable list -->
<li role="option" aria-selected="false"> <!-- List option -->Live Region Attributes
<!-- Polite: Announces when user idle -->
<div aria-live="polite" aria-atomic="true">
New message received
</div>
<!-- Assertive: Interrupts immediately (use for errors) -->
<div aria-live="assertive" role="alert">
Connection lost
</div>
<!-- Atomic: Announce entire region vs just changes -->
<div aria-live="polite" aria-atomic="true">
3 items in cart <!-- Reads "3 items in cart", not just "3" -->
</div>---
Color Contrast
Calculating Contrast Ratio
Contrast Ratio = (L1 + 0.05) / (L2 + 0.05)
Where L1 = lighter color luminance, L2 = darker color luminanceCommon Safe Combinations
| Background | Text | Ratio |
|---|---|---|
#FFFFFF | #000000 | 21:1 |
#FFFFFF | #595959 | 7:1 |
#FFFFFF | #767676 | 4.54:1 (minimum) |
#FFFFFF | #949494 | 2.94:1 (fails) |
#1a1a1a | #FFFFFF | 17.4:1 |
#007bff | #FFFFFF | 4.5:1 (barely) |
Tools
- WebAIM Contrast Checker
- Colour Contrast Analyser
- Chrome DevTools: Inspect element → Color picker shows contrast
---
Focus Management Patterns
Focus Trap (for modals)
function trapFocus(element: HTMLElement) {
const focusableElements = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusableElements[0] as HTMLElement;
const last = focusableElements[focusableElements.length - 1] as HTMLElement;
element.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first.focus();
}Return Focus on Close
let previouslyFocused: HTMLElement | null = null;
function openModal() {
previouslyFocused = document.activeElement as HTMLElement;
modal.showModal();
trapFocus(modal);
}
function closeModal() {
modal.close();
previouslyFocused?.focus();
}---
Shadow DOM Accessibility
Challenges
1. ARIA references can't cross shadow boundary - aria-labelledby IDs must be in same DOM tree 2. Focus delegation - Use delegatesFocus: true in attachShadow 3. Form participation - Custom elements need ElementInternals for form association
Solutions
// Enable focus delegation
this.attachShadow({ mode: 'open', delegatesFocus: true });
// Use aria-label instead of aria-labelledby for cross-boundary
<button aria-label="Send message"> // Works in Shadow DOM
<button aria-labelledby="label-id"> // Won't find ID outside shadow
// Form association with ElementInternals
class MyInput extends HTMLElement {
static formAssociated = true;
#internals: ElementInternals;
constructor() {
super();
this.#internals = this.attachInternals();
}
get value() { return this.#internals.value; }
set value(v) { this.#internals.setFormValue(v); }
}Related skills
FAQ
What contrast ratios does it require?
4.5:1 minimum for normal text and 3:1 for large text (18pt+) and interactive elements under WCAG AA.
How does it recommend building accordions?
Use native <details>/<summary> elements instead of custom JavaScript accordions so keyboard navigation and expanded/collapsed announcements work automatically.