Resources
scripts/ validate-accessibility-audit.sh references/ accessibility-patterns.md
Accessibility Audit
This skill guides you through performing comprehensive accessibility audits to ensure WCAG 2.1 AA compliance. Use this when validating applications for inclusive design, preparing for accessibility reviews, or remediating accessibility issues.
When to Use This Skill
-
Conducting pre-launch accessibility compliance reviews
-
Responding to accessibility complaints or legal requirements
-
Performing periodic accessibility audits on existing applications
-
Validating accessibility after major UI refactors
-
Preparing for WCAG 2.1 AA certification
-
Onboarding teams to accessibility best practices
Audit Methodology
A systematic accessibility audit follows these phases:
Phase 1: Reconnaissance
Objective: Map components and identify accessibility-critical areas.
Use discover to map the UI surface:
discover: queries: - id: interactive_components type: grep pattern: "(button|input|select|textarea|a href)" glob: "/*.{tsx,jsx}" - id: form_components type: grep pattern: "<form|onSubmit|FormProvider" glob: "/.{tsx,jsx}" - id: aria_usage type: grep pattern: "aria-(label|labelledby|describedby|live|role)" glob: "**/.{tsx,jsx}" - id: image_components type: grep pattern: "(<img|<Image|next/image)" glob: "**/*.{tsx,jsx}" verbosity: files_only
Identify critical areas:
-
Forms and input fields
-
Navigation menus and routing
-
Modal dialogs and overlays
-
Data tables and lists
-
Media players (audio/video)
-
Custom interactive widgets
Phase 2: Semantic HTML Audit
Objective: Verify proper use of HTML5 semantic elements.
Check Heading Hierarchy
Search for heading usage:
precision_grep: queries: - id: heading_elements pattern: "<h[1-6]|heading.level" glob: "**/.{tsx,jsx}" output: format: standard
Common violations:
-
Skipping heading levels (h1 -> h3 without h2)
-
Multiple h1 elements on a page
-
Using headings for visual styling instead of structure
-
Missing heading hierarchy in complex components
Correct heading structure:
import { ReactNode } from 'react';
interface PageLayoutProps { title: string; children: ReactNode; }
export function PageLayout({ title, children }: PageLayoutProps) { return ( <div className="page-layout"> {/* Single h1 per page */} <h1 className="text-3xl font-bold">{title}</h1>
<main>
{children}
</main>
</div>
); }
interface SectionProps { title: string; children: ReactNode; }
export function Section({ title, children }: SectionProps) { return ( <section> {/* h2 for main sections */} <h2 className="text-2xl font-semibold">{title}</h2> {children} </section> ); }
interface SubsectionProps { title: string; children: ReactNode; }
export function Subsection({ title, children }: SubsectionProps) { return ( <div> {/* h3 for subsections */} <h3 className="text-xl font-medium">{title}</h3> {children} </div> ); }
Check Landmark Regions
Search for landmark usage:
precision_grep: queries: - id: landmarks pattern: "(<header|<main|<nav|<aside|<footer|role="(banner|navigation|main|complementary|contentinfo)")" glob: "**/*.{tsx,jsx}" output: format: files_only
Required landmarks:
-
<header> or role="banner" for site header
-
<nav> or role="navigation" for navigation
-
<main> or role="main" for primary content (exactly one per page)
-
<aside> or role="complementary" for sidebars
-
<footer> or role="contentinfo" for site footer
Proper landmark structure:
import { ReactNode } from 'react';
interface AppLayoutProps { navigation: ReactNode; sidebar?: ReactNode; children: ReactNode; }
export function AppLayout({ navigation, sidebar, children }: AppLayoutProps) { return ( <div className="app-layout"> <header className="site-header"> <div className="logo">MyApp</div> {navigation} </header>
<div className="content-container">
{sidebar && (
<aside className="sidebar" aria-label="Filters">
{sidebar}
</aside>
)}
{/* Exactly one main per page */}
<main className="main-content">
{children}
</main>
</div>
<footer className="site-footer">
<p>&copy; 2026 MyApp. All rights reserved.</p>
</footer>
</div>
); }
Check List Semantics
Search for list patterns:
precision_grep: queries: - id: list_elements pattern: "<(ul|ol|li|dl|dt|dd)" glob: "**/*.{tsx,jsx}" output: format: files_only
Common violations:
-
Using <div> for lists instead of <ul> or <ol>
-
Nesting <li> outside of <ul> or <ol>
-
Using lists for layout instead of semantic grouping
Semantic list usage:
interface User { id: string; name: string; email: string; }
interface UserListProps { users: User[]; }
export function UserList({ users }: UserListProps) { return ( <ul className="user-list" aria-label="Team members"> {users.map((user) => ( <li key={user.id}> <span className="user-name">{user.name}</span> <span className="user-email">{user.email}</span> </li> ))} </ul> ); }
Phase 3: ARIA Patterns Audit
Objective: Validate correct ARIA usage and patterns.
Check ARIA Roles
Search for ARIA role usage:
precision_grep: queries: - id: aria_roles pattern: 'role="(button|link|dialog|alertdialog|menu|menuitem|tab|tabpanel|listbox|option)"' glob: "**/*.{tsx,jsx}" output: format: standard
Common violations:
-
Redundant roles on semantic elements (e.g., <button role="button"> )
-
Using role="button" without keyboard handlers
-
Missing required ARIA attributes for complex roles
-
Using abstract roles (e.g., role="widget" )
Correct ARIA button pattern:
import { MouseEvent, KeyboardEvent } from 'react';
interface CustomButtonProps { onClick: () => void; disabled?: boolean; children: React.ReactNode; }
export function CustomButton({ onClick, disabled, children }: CustomButtonProps) { const handleClick = (e: MouseEvent<HTMLDivElement>) => { if (!disabled) { onClick(); } };
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if (!disabled && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick(); } };
return ( <div role="button" tabIndex={disabled ? -1 : 0} aria-disabled={disabled} onClick={handleClick} onKeyDown={handleKeyDown} className="custom-button" > {children} </div> ); }
Note: Prefer native <button> element when possible. Only use role="button" on non-button elements when absolutely necessary.
Check ARIA Labels and Descriptions
Search for labeling patterns:
precision_grep: queries: - id: aria_labels pattern: "aria-(label|labelledby|describedby)" glob: "**/*.{tsx,jsx}" output: format: context
Common violations:
-
Interactive elements without accessible names
-
aria-labelledby referencing non-existent IDs
-
Redundant labels (both aria-label and aria-labelledby )
-
Using aria-label on non-interactive elements
Correct labeling patterns:
import { useId } from 'react';
interface SearchFormProps { onSearch: (query: string) => void; }
export function SearchForm({ onSearch }: SearchFormProps) { const searchId = useId(); const hintId = useId();
return ( <form role="search" onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); onSearch(formData.get('query') as string); }}> <label htmlFor={searchId} className="sr-only"> Search articles </label> <input id={searchId} type="search" name="query" placeholder="Search..." aria-describedby={hintId} /> <p id={hintId} className="text-sm text-gray-600"> Search by title, author, or keyword </p> <button type="submit" aria-label="Submit search"> <SearchIcon aria-hidden="true" /> </button> </form> ); }
Check Live Regions
Search for live region usage:
precision_grep: queries: - id: live_regions pattern: 'aria-live="(polite|assertive|off)"|role="(status|alert)"' glob: "**/*.{tsx,jsx}" output: format: standard
Live region best practices:
-
Use aria-live="polite" for non-critical updates
-
Use aria-live="assertive" or role="alert" for urgent messages
-
Use role="status" for status updates (implicitly aria-live="polite" )
-
Ensure live region exists in DOM before content updates
Accessible notification pattern:
import { ReactNode, useEffect, useState } from 'react';
interface NotificationProps { message: string; type: 'success' | 'error' | 'info'; onDismiss: () => void; }
export function Notification({ message, type, onDismiss }: NotificationProps) { useEffect(() => { const timer = setTimeout(onDismiss, 5000); return () => clearTimeout(timer); }, [onDismiss]);
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
className={notification notification-${type}}
>
<p>{message}</p>
<button onClick={onDismiss} aria-label="Dismiss notification">
<CloseIcon aria-hidden="true" />
</button>
</div>
);
}
Phase 4: Keyboard Navigation Audit
Objective: Ensure full keyboard accessibility.
Check Focus Management
Search for focus-related code:
precision_grep: queries: - id: focus_management pattern: "(focus\(\)|autoFocus|tabIndex|useRef.focus)" glob: "**/.{tsx,jsx,ts}" output: format: standard
Common violations:
-
Missing focus indicators (:focus styles)
-
Focus traps in modals without escape mechanism
-
Interactive elements with tabIndex="-1" that should be reachable
-
Auto-focusing elements on page load unnecessarily
Accessible modal with focus trap:
import { useEffect, useRef, ReactNode } from 'react'; import { createPortal } from 'react-dom';
interface ModalProps { isOpen: boolean; onClose: () => void; title: string; children: ReactNode; }
export function Modal({ isOpen, onClose, title, children }: ModalProps) { const modalRef = useRef<HTMLDivElement>(null); const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => { if (!isOpen) return;
// Save previously focused element
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element in modal
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements && focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
// Trap focus within modal
if (e.key === 'Tab' && focusableElements) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal( <div className="modal-overlay" onClick={onClose}> <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" className="modal-content" onClick={(e) => e.stopPropagation()} > <h2 id="modal-title">{title}</h2> <div className="modal-body">{children}</div> <button onClick={onClose} className="modal-close"> Close </button> </div> </div>, document.body ); }
Check Skip Links
Search for skip link implementation:
precision_grep: queries: - id: skip_links pattern: "(skip.*main|skip.*content|skip.navigation)" glob: "**/.{tsx,jsx}" output: format: files_only
Skip link implementation:
export function SkipLink() { return ( <a href="#main-content" className="skip-link" > Skip to main content </a> ); }
// CSS (in global styles) // .skip-link { // position: absolute; // top: -40px; // left: 0; // background: #000; // color: #fff; // padding: 8px; // text-decoration: none; // z-index: 100; // } // // .skip-link:focus { // top: 0; // }
Check Keyboard Event Handlers
Search for onClick without keyboard support:
precision_grep: queries: - id: onclick_handlers pattern: "onClick=\{" glob: "**/*.{tsx,jsx}" output: format: locations
Manual review: For each onClick handler on a non-button/non-link element, verify:
-
Element has role="button" or appropriate role
-
Element has tabIndex={0} for keyboard focus
-
onKeyDown handler responds to Enter and Space keys
Accessible click handler pattern:
import { MouseEvent, KeyboardEvent } from 'react';
interface ClickableCardProps { title: string; onClick: () => void; }
export function ClickableCard({ title, onClick }: ClickableCardProps) { const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } };
return ( <div role="button" tabIndex={0} onClick={onClick} onKeyDown={handleKeyDown} className="clickable-card" > <h3>{title}</h3> </div> ); }
Phase 5: Screen Reader Support Audit
Objective: Verify screen reader compatibility.
Check Visually Hidden Text
Search for sr-only patterns:
precision_grep: queries: - id: sr_only pattern: "(sr-only|visually-hidden|screen-reader)" glob: "**/*.{tsx,jsx,css,scss}" output: format: files_only
Visually hidden text for icons:
interface IconButtonProps { onClick: () => void; ariaLabel: string; icon: React.ReactNode; }
export function IconButton({ onClick, ariaLabel, icon }: IconButtonProps) { return ( <button onClick={onClick} aria-label={ariaLabel}> {icon} <span className="sr-only">{ariaLabel}</span> </button> ); }
// CSS for sr-only class: // .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; // }
Check aria-hidden Usage
Search for aria-hidden:
precision_grep: queries: - id: aria_hidden pattern: 'aria-hidden="true"' glob: "**/*.{tsx,jsx}" output: format: standard
Common violations:
-
aria-hidden="true" on interactive elements (buttons, links)
-
aria-hidden="true" on content that should be accessible
-
Decorative icons without aria-hidden="true"
Correct aria-hidden usage:
interface ButtonWithIconProps { onClick: () => void; label: string; }
export function ButtonWithIcon({ onClick, label }: ButtonWithIconProps) { return ( <button onClick={onClick}> {/* Icon is decorative, hide from screen readers /} <CheckIcon aria-hidden="true" /> {/ Label provides accessible text */} <span>{label}</span> </button> ); }
Phase 6: Color and Contrast Audit
Objective: Ensure sufficient color contrast and no color-only information.
Check Contrast Ratios
Use browser DevTools or external tools:
-
Chrome DevTools: Inspect element -> Accessibility pane -> Contrast ratio
-
Firefox DevTools: Inspector -> Accessibility panel
-
axe DevTools browser extension
-
Lighthouse accessibility audit
WCAG 2.1 AA requirements:
-
Normal text (< 18pt): 4.5:1 minimum contrast ratio
-
Large text (>= 18pt or >= 14pt bold): 3:1 minimum contrast ratio
-
UI components and graphics: 3:1 minimum contrast ratio
Search for color definitions:
precision_grep: queries: - id: color_definitions pattern: "(bg-|text-|color:|background:)" glob: "**/*.{tsx,jsx,css,scss}" output: format: count_only
Accessible color palette (Tailwind example):
// tailwind.config.ts import type { Config } from 'tailwindcss';
const config: Config = { theme: { extend: { colors: { // Accessible color palette with documented contrast ratios primary: { 50: '#f0f9ff', 100: '#e0f2fe', 600: '#0284c7', // 4.54:1 on white (WCAG AA) 700: '#0369a1', // 7.09:1 on white (WCAG AAA) 900: '#0c4a6e', // 13.14:1 on white }, // Error color with good contrast error: { 600: '#dc2626', // 4.51:1 on white 700: '#b91c1c', // 6.70:1 on white }, // Success color success: { 600: '#16a34a', // 4.51:1 on white 700: '#15803d', // 6.68:1 on white }, }, }, }, };
export default config;
Check Color-Only Information
Search for status indicators:
precision_grep: queries: - id: status_colors pattern: "(bg-red|bg-green|bg-yellow|text-red|text-green|text-yellow)" glob: "**/*.{tsx,jsx}" output: format: standard
Manual review: Ensure status is conveyed through multiple means (color + icon + text).
Accessible status indicator:
type Status = 'success' | 'warning' | 'error' | 'info';
interface StatusBadgeProps { status: Status; message: string; }
const statusConfig: Record<Status, { icon: React.ReactNode; className: string }> = { success: { icon: <CheckCircleIcon />, className: 'bg-green-100 text-green-800' }, warning: { icon: <AlertTriangleIcon />, className: 'bg-yellow-100 text-yellow-800' }, error: { icon: <XCircleIcon />, className: 'bg-red-100 text-red-800' }, info: { icon: <InfoIcon />, className: 'bg-blue-100 text-blue-800' }, };
export function StatusBadge({ status, message }: StatusBadgeProps) { const { icon, className } = statusConfig[status];
return (
<div className={flex items-center gap-2 px-3 py-2 rounded ${className}}>
{/* Icon provides visual indicator beyond color /}
<span aria-hidden="true">{icon}</span>
{/ Text provides clear status information */}
<span>{message}</span>
</div>
);
}
Check Forced Colors Mode
Search for forced-colors media query:
precision_grep: queries: - id: forced_colors pattern: "@media.forced-colors|prefers-contrast" glob: "**/.{css,scss,tsx,jsx}" output: format: files_only
Support Windows High Contrast Mode:
/* Ensure borders are visible in forced-colors mode */ .card { border: 1px solid #e5e7eb; }
@media (forced-colors: active) { .card { border: 1px solid CanvasText; } }
/* Ensure custom controls are visible */ .custom-checkbox { border: 2px solid #3b82f6; }
@media (forced-colors: active) { .custom-checkbox { border: 2px solid ButtonText; }
.custom-checkbox:checked { background-color: Highlight; } }
Phase 7: Forms and Validation Audit
Objective: Ensure forms are accessible and error handling is clear.
Check Form Labels
Search for input elements:
precision_grep: queries: - id: input_elements pattern: "<input|<textarea|<select" glob: "**/*.{tsx,jsx}" output: format: locations
Manual review: Verify each input has an associated label via:
-
<label> with matching htmlFor attribute
-
aria-label attribute
-
aria-labelledby pointing to label element
Common violations:
-
Placeholder-only inputs without labels
-
Labels without htmlFor attribute
-
Multiple inputs sharing one label
Accessible form pattern:
import { useId, FormEvent } from 'react';
interface FormData { email: string; password: string; }
interface LoginFormProps { onSubmit: (data: FormData) => Promise<void>; }
export function LoginForm({ onSubmit }: LoginFormProps) { const emailId = useId(); const passwordId = useId(); const emailErrorId = useId(); const passwordErrorId = useId();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); await onSubmit({ email: formData.get('email') as string, password: formData.get('password') as string, }); };
return ( <form onSubmit={handleSubmit} noValidate> <div className="form-field"> {/* Explicit label with htmlFor /} <label htmlFor={emailId}> Email address </label> <input id={emailId} type="email" name="email" required aria-required="true" aria-describedby={emailErrorId} autoComplete="email" /> {/ Error message linked via aria-describedby /} <p id={emailErrorId} className="error-message" role="alert"> {/ Error text populated on validation */} </p> </div>
<div className="form-field">
<label htmlFor={passwordId}>
Password
</label>
<input
id={passwordId}
type="password"
name="password"
required
aria-required="true"
aria-describedby={passwordErrorId}
autoComplete="current-password"
/>
<p id={passwordErrorId} className="error-message" role="alert">
{/* Error text populated on validation */}
</p>
</div>
<button type="submit">
Sign in
</button>
</form>
); }
Check Error Handling
Search for error patterns:
precision_grep: queries: - id: error_messages pattern: "(error|invalid|required).message" glob: "**/.{tsx,jsx,ts}" output: format: standard
Accessible error handling:
import { useState, useId } from 'react';
interface FieldError { field: string; message: string; }
interface FormWithValidationProps { onSubmit: (data: Record<string, string>) => void; }
export function FormWithValidation({ onSubmit }: FormWithValidationProps) { const [errors, setErrors] = useState<FieldError[]>([]); const nameId = useId(); const nameErrorId = useId(); const errorSummaryId = useId();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); const name = formData.get('name') as string;
// Validation
const newErrors: FieldError[] = [];
if (!name || name.length < 2) {
newErrors.push({ field: 'name', message: 'Name must be at least 2 characters' });
}
if (newErrors.length > 0) {
setErrors(newErrors);
// Focus error summary for screen readers
document.getElementById(errorSummaryId)?.focus();
return;
}
setErrors([]);
onSubmit({ name });
};
const nameError = errors.find((e) => e.field === 'name');
return (
<form onSubmit={handleSubmit} noValidate>
{/* Error summary at top of form */}
{errors.length > 0 && (
<div
id={errorSummaryId}
role="alert"
aria-labelledby="error-summary-title"
className="error-summary"
tabIndex={-1}
>
<h2 id="error-summary-title">There are {errors.length} errors</h2>
<ul>
{errors.map((error) => (
<li key={error.field}>
<a href={#${error.field}}>{error.message}</a>
</li>
))}
</ul>
</div>
)}
<div className="form-field">
<label htmlFor={nameId}>Name</label>
<input
id={nameId}
name="name"
aria-required="true"
aria-invalid={nameError ? 'true' : 'false'}
aria-describedby={nameError ? nameErrorId : undefined}
/>
{nameError && (
<p id={nameErrorId} className="error-message" role="alert">
{nameError.message}
</p>
)}
</div>
<button type="submit">Submit</button>
</form>
); }
Check Required Fields
Search for required field indicators:
precision_grep: queries: - id: required_fields pattern: "(required|aria-required)" glob: "**/*.{tsx,jsx}" output: format: files_only
Accessible required field indicator:
interface RequiredFieldLabelProps { htmlFor: string; children: React.ReactNode; }
export function RequiredFieldLabel({ htmlFor, children }: RequiredFieldLabelProps) { return ( <label htmlFor={htmlFor}> {children} {/* Visual indicator */} <span className="text-red-600" aria-hidden="true"> </span> {/ Screen reader text */} <span className="sr-only"> (required)</span> </label> ); }
Phase 8: Media Accessibility Audit
Objective: Ensure images, videos, and audio content are accessible.
Check Image Alt Text
Search for images:
precision_grep: queries: - id: img_elements pattern: "(<img|<Image|next/image)" glob: "**/*.{tsx,jsx}" output: format: standard
Manual review: Verify each image has:
-
Meaningful alt text for content images
-
Empty alt="" for decorative images
-
Alt text that describes function for linked images
Common violations:
-
Missing alt attribute
-
Generic alt text ("image", "photo", "icon")
-
Redundant alt text ("image of...", "picture of...")
-
Alt text describing file name ("IMG_1234.jpg")
Accessible image patterns:
import Image from 'next/image';
interface ProductImageProps { src: string; productName: string; }
// Content image with descriptive alt
export function ProductImage({ src, productName }: ProductImageProps) {
return (
<Image
src={src}
alt={${productName} product photo}
width={400}
height={400}
/>
);
}
// Decorative image with empty alt export function DecorativePattern() { return ( <div className="background-pattern"> <Image src="/patterns/dots.svg" alt="" fill aria-hidden="true" /> </div> ); }
// Functional image (icon button) interface DeleteButtonProps { onDelete: () => void; }
export function DeleteButton({ onDelete }: DeleteButtonProps) { return ( <button onClick={onDelete} aria-label="Delete item"> <Image src="/icons/trash.svg" alt="" width={20} height={20} aria-hidden="true" /> </button> ); }
Check Video Captions
Search for video elements:
precision_grep: queries: - id: video_elements pattern: "(<video|<track)" glob: "**/*.{tsx,jsx}" output: format: standard
Accessible video with captions:
interface AccessibleVideoProps { src: string; captionsSrc: string; title: string; }
export function AccessibleVideo({ src, captionsSrc, title }: AccessibleVideoProps) { return ( <video controls aria-label={title}> <source src={src} type="video/mp4" /> {/* Captions for deaf/hard of hearing users /} <track kind="captions" src={captionsSrc} srcLang="en" label="English captions" default /> {/ Fallback text */} <p> Your browser does not support the video element. <a href={src}>Download the video</a> </p> </video> ); }
Check Reduced Motion
Search for animations:
precision_grep: queries: - id: animations pattern: "(animate|transition|prefers-reduced-motion)" glob: "**/*.{tsx,jsx,css,scss}" output: format: files_only
Respect prefers-reduced-motion:
import { motion, useReducedMotion } from 'framer-motion';
interface AnimatedCardProps { children: React.ReactNode; }
export function AnimatedCard({ children }: AnimatedCardProps) { const shouldReduceMotion = useReducedMotion();
return ( <motion.div initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: shouldReduceMotion ? 0 : 0.3, }} > {children} </motion.div> ); }
CSS approach:
/* Default: animated */ .card { transition: transform 0.3s ease; }
.card:hover { transform: scale(1.05); }
/* Reduced motion: disable animations */ @media (prefers-reduced-motion: reduce) { .card { transition: none; }
.card:hover { transform: none; } }
Automated Testing
Precision Tool Workflow
Run comprehensive accessibility checks:
discover: queries: # Missing alt attributes - id: missing_alt type: grep pattern: "<img(?![^>]alt=)" glob: "**/.{tsx,jsx}"
# onClick without keyboard handler
- id: onclick_no_keyboard
type: grep
pattern: 'onClick=\\{(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Non-button with role=button without keyboard
- id: role_button_no_keyboard
type: grep
pattern: 'role="button"(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Missing form labels
- id: unlabeled_inputs
type: grep
pattern: "<input(?![^>]*aria-label)(?![^>]*id=)"
glob: "**/*.{tsx,jsx}"
# Heading level skips
- id: heading_usage
type: grep
pattern: "<h[1-6]"
glob: "**/*.{tsx,jsx}"
verbosity: locations
Browser-Based Testing
Run these tools for comprehensive automated testing:
Lighthouse Accessibility Audit
npx lighthouse https://localhost:3000 --only-categories=accessibility --view
axe DevTools
-
Install: axe DevTools Chrome Extension
-
Run: F12 -> axe DevTools -> Scan
Pa11y CI
npm install --save-dev pa11y-ci npx pa11y-ci --sitemap https://yoursite.com/sitemap.xml
Audit Reporting
Structure Findings by WCAG Criteria
Report template:
Accessibility Audit Report
Date: 2026-02-16 Auditor: [Name] WCAG Version: 2.1 Level AA Pages Audited: 12
Executive Summary
- Critical Issues: 3
- Serious Issues: 8
- Moderate Issues: 15
- Minor Issues: 22
Findings by WCAG Principle
1. Perceivable
1.1.1 Non-text Content (Level A) - FAIL
Issue: 12 images missing alt text
Location:
src/components/ProductCard.tsx:45src/components/Gallery.tsx:78
Impact: Screen reader users cannot understand image content
Recommendation:
// Before
<img src="/product.jpg" />
// After
<img src="/product.jpg" alt="Blue cotton t-shirt" />
1.4.3 Contrast (Minimum) (Level AA) - FAIL
Issue: Primary button text has 3.2:1 contrast ratio (requires 4.5:1)
Location: src/styles/globals.css:45
Impact: Low vision users may not be able to read button text
Recommendation:
/* Before: #60a5fa on #3b82f6 = 3.2:1 */
.btn-primary {
background: #3b82f6;
color: #60a5fa;
}
/* After: white on #2563eb = 4.5:1 */
.btn-primary {
background: #2563eb;
color: #ffffff;
}
2. Operable
2.1.1 Keyboard (Level A) - FAIL
Issue: Custom dropdown not keyboard accessible
Location: src/components/Dropdown.tsx
Impact: Keyboard users cannot operate dropdown
Recommendation: Implement arrow key navigation and Enter/Space activation
3. Understandable
3.3.2 Labels or Instructions (Level A) - FAIL
Issue: Form inputs missing visible labels
Location: src/components/ContactForm.tsx:23-45
Recommendation: Add explicit <label>
elements with htmlFor
attributes
4. Robust
4.1.2 Name, Role, Value (Level A) - FAIL
Issue: Custom checkbox missing ARIA states
Location: src/components/CustomCheckbox.tsx
Recommendation: Add aria-checked
state and role="checkbox"
Priority Actions
- Critical: Add alt text to all images (affects 100% of screen reader users)
- Critical: Fix keyboard navigation in dropdown (blocks keyboard users)
- High: Fix color contrast on primary buttons (affects ~4.5% of users)
- High: Add form labels (affects screen reader and cognitive users)
## Related Skills
- **code-review** - Apply accessibility checks during code review
- **component-architecture** - Design accessible component APIs
- **testing-strategy** - Include accessibility in test suites
## References
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)