Accessibility is not an afterthought — retrofitting is significantly more expensive than building accessibly from the start. WCAG 2.1 AA requires sufficient color contrast, keyboard navigability, screen reader labels, and visible focus indicators. Claude Code audits existing components for accessibility issues, implements ARIA patterns correctly, and generates keyboard navigation logic for complex widgets like comboboxes, modals, and data grids.
CLAUDE.md for Accessibility-First Projects
## Accessibility Standards
- Target: WCAG 2.1 AA (minimum), WCAG 2.2 AA where possible
- Testing: axe-core in Jest (automated), NVDA + Chrome (screen reader), keyboard-only navigation
- Color contrast: minimum 4.5:1 for normal text, 3:1 for large text and UI components
- Focus: all interactive elements have visible focus indicator (outline: 2px solid #0070f3)
- ARIA: only use ARIA when native HTML semantics are insufficient
- Images: all informational images have alt text; decorative images have alt=""
- Forms: every input has a visible label (not placeholder-only)
- Motion: prefers-reduced-motion honored for all animations
Accessible Modal Dialog
// components/Modal.tsx — ARIA dialog pattern
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
initialFocusRef?: React.RefObject<HTMLElement>;
}
export function Modal({ isOpen, onClose, title, children, initialFocusRef }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Save focus so we can restore it on close
previousFocusRef.current = document.activeElement as HTMLElement;
// Move focus to the dialog (or specified initial element)
requestAnimationFrame(() => {
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
} else {
dialogRef.current?.focus();
}
});
} else {
// Restore focus when modal closes
previousFocusRef.current?.focus();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
// Trap focus inside modal
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (!focusable || 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();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<>
{/* Backdrop: dismisses on click, but is not focusable */}
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1} // Makes div focusable programmatically
className="modal"
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="modal-close"
>
× {/* Don't rely on visual-only × for meaning */}
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</>,
document.body
);
}
Accessible Combobox (Autocomplete)
// The combobox pattern is complex — ARIA has specific requirements
function Combobox({ id, label, options, onSelect }: ComboboxProps) {
const [inputValue, setInputValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const filteredOptions = options.filter(o =>
o.label.toLowerCase().includes(inputValue.toLowerCase())
);
const listboxId = `${id}-listbox`;
const activeOptionId = activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined;
return (
<div className="combobox-container">
<label htmlFor={id}>{label}</label>
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-owns={listboxId}
>
<input
id={id}
type="text"
value={inputValue}
onChange={e => { setInputValue(e.target.value); setIsOpen(true); setActiveIndex(-1); }}
onKeyDown={e => handleKeyDown(e, filteredOptions, activeIndex, setActiveIndex, onSelect, setIsOpen)}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeOptionId}
role="textbox"
/>
</div>
{isOpen && filteredOptions.length > 0 && (
<ul
id={listboxId}
role="listbox"
aria-label={label}
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${id}-option-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => { onSelect(option); setIsOpen(false); setInputValue(option.label); }}
className={index === activeIndex ? 'active' : ''}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
function handleKeyDown(
e: React.KeyboardEvent,
options: Option[],
activeIndex: number,
setActiveIndex: (i: number) => void,
onSelect: (o: Option) => void,
setIsOpen: (open: boolean) => void,
) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(Math.min(activeIndex + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(Math.max(activeIndex - 1, 0));
break;
case 'Enter':
if (activeIndex >= 0) {
e.preventDefault();
onSelect(options[activeIndex]);
setIsOpen(false);
}
break;
case 'Escape':
setIsOpen(false);
setActiveIndex(-1);
break;
}
}
Live Regions for Dynamic Updates
// Announce dynamic content to screen readers without moving focus
function OrderStatus({ status }: { status: string }) {
return (
<>
{/* aria-live="polite" announces after user finishes current action */}
<div aria-live="polite" aria-atomic="true">
{status && `Order status: ${status}`}
</div>
{/* aria-live="assertive" interrupts immediately (use sparingly — errors only) */}
{status === 'FAILED' && (
<div role="alert" aria-live="assertive">
Payment failed. Please try again or contact support.
</div>
)}
</>
);
}
// Pattern: status announcer hook
function useAnnouncer() {
const [message, setMessage] = useState('');
const announce = useCallback((msg: string) => {
setMessage(''); // Clear first to ensure re-announcement of same message
requestAnimationFrame(() => setMessage(msg));
}, []);
const AnnouncerElement = () => (
<div
aria-live="polite"
aria-atomic="true"
className="sr-only" // visually hidden but available to screen readers
>
{message}
</div>
);
return { announce, AnnouncerElement };
}
Color Contrast Audit
// scripts/audit-contrast.ts — check all color combinations in design tokens
import Color from 'colorjs.io';
const tokens = {
textPrimary: '#1a1a1a',
textSecondary: '#6b7280',
textDisabled: '#9ca3af',
backgroundWhite: '#ffffff',
backgroundGray: '#f9fafb',
primaryBlue: '#2563eb',
};
function checkContrast(foreground: string, background: string, context: string) {
const fg = new Color(foreground);
const bg = new Color(background);
const contrast = fg.contrast(bg, 'WCAG21');
const normalTextPass = contrast >= 4.5;
const largeTextPass = contrast >= 3;
console.log(`${context}: ${contrast.toFixed(2)}:1 ${normalTextPass ? '✅' : '❌'}`);
if (!normalTextPass) {
console.warn(` FAIL: ${foreground} on ${background} — needs 4.5:1, has ${contrast.toFixed(2)}:1`);
}
}
checkContrast(tokens.textPrimary, tokens.backgroundWhite, 'Primary text on white');
checkContrast(tokens.textSecondary, tokens.backgroundWhite, 'Secondary text on white');
checkContrast(tokens.textDisabled, tokens.backgroundWhite, 'Disabled text on white'); // Will fail — that's intentional for disabled
checkContrast(tokens.primaryBlue, tokens.backgroundWhite, 'Primary button on white');
Automated Testing with axe-core
// tests/accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Modal } from '../components/Modal';
import { Combobox } from '../components/Combobox';
expect.extend(toHaveNoViolations);
describe('Accessibility', () => {
it('Modal has no accessibility violations when open', async () => {
const { container } = render(
<Modal isOpen={true} onClose={() => {}} title="Confirm Action">
<p>Are you sure?</p>
<button>Cancel</button>
<button>Confirm</button>
</Modal>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('Combobox has no accessibility violations', async () => {
const { container } = render(
<Combobox
id="product-search"
label="Search products"
options={[{ value: '1', label: 'Apple' }, { value: '2', label: 'Banana' }]}
onSelect={() => {}}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('Form inputs have associated labels', async () => {
const { container } = render(
<form>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
<label htmlFor="password">Password</label>
<input id="password" type="password" />
<button type="submit">Submit</button>
</form>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
CSS: Focus Visible + Reduced Motion
/* Focus visible: keyboard navigation only (not mouse/touch) */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
border-radius: 2px;
}
/* Remove default outline for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
/* Screen-reader only: visually hidden but accessible */
.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;
}
/* Respect user's motion preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
For the React component architecture that hosts these accessible patterns, see the React guide. For building the test infrastructure that runs axe-core in CI, the Playwright testing guide covers end-to-end accessibility auditing. The Claude Skills 360 bundle includes accessibility skill sets covering ARIA patterns, keyboard navigation, and WCAG compliance auditing. Start with the free tier to try accessibility audit generation.