Homepage (site/index.html): integration-v14 promoted, Writings section integrated with 33 pieces clustered by type (stories/essays/miscellany), Writings welcome lightbox, content frame at 98% opacity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
17 KiB
17 KiB
Accessibility Reference
Comprehensive guide for implementing accessible interfaces following WCAG 2.1 AA standards.
Core Principles (POUR)
Perceivable
Information and UI components must be presentable to users in ways they can perceive.
Operable
UI components and navigation must be operable by all users.
Understandable
Information and the operation of UI must be understandable.
Robust
Content must be robust enough to be interpreted by a wide variety of user agents, including assistive technologies.
Semantic HTML
Use Appropriate Elements
Good:
<header>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Article Title</h1>
<p>Article content...</p>
</article>
</main>
<footer>
<p>© 2025 Company Name</p>
</footer>
Bad:
<div class="header">
<div class="nav">
<div class="link">Home</div>
<div class="link">About</div>
</div>
</div>
Heading Hierarchy
Correct hierarchy:
<h1>Page Title</h1>
<h2>Section 1</h2>
<h3>Subsection 1.1</h3>
<h3>Subsection 1.2</h3>
<h2>Section 2</h2>
<h3>Subsection 2.1</h3>
Incorrect (skips levels):
<h1>Page Title</h1>
<h4>Section 1</h4> // ❌ Skips h2 and h3
Keyboard Navigation
Focus Management
// Ensure all interactive elements are keyboard accessible
<button
className="
px-4 py-2
focus:outline-none
focus:ring-4 focus:ring-blue-500
focus:ring-offset-2
rounded-lg
"
tabIndex={0}
>
Accessible Button
</button>
// Custom interactive elements need tabindex
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
className="cursor-pointer focus:ring-4 focus:ring-blue-500"
>
Custom Button
</div>
Tab Order
// Use tabIndex to control focus order
<form>
<input tabIndex={1} aria-label="First name" />
<input tabIndex={2} aria-label="Last name" />
<input tabIndex={3} aria-label="Email" />
<button tabIndex={4}>Submit</button>
</form>
// Use tabIndex={-1} to remove from tab order but allow programmatic focus
<div tabIndex={-1} id="error-message">
Error details...
</div>
Skip Links
// Allow keyboard users to skip to main content
<a
href="#main-content"
className="
sr-only
focus:not-sr-only
focus:absolute
focus:top-4 focus:left-4
focus:z-50
focus:px-4 focus:py-2
focus:bg-blue-600 focus:text-white
focus:rounded-lg
"
>
Skip to main content
</a>
<main id="main-content">
{/* Main content */}
</main>
ARIA Attributes
Common ARIA Roles
// Navigation landmark
<nav role="navigation" aria-label="Main navigation">
{/* Navigation items */}
</nav>
// Banner (header)
<header role="banner">
{/* Header content */}
</header>
// Main content
<main role="main">
{/* Main content */}
</main>
// Complementary (sidebar)
<aside role="complementary" aria-label="Related articles">
{/* Sidebar content */}
</aside>
// Content info (footer)
<footer role="contentinfo">
{/* Footer content */}
</footer>
// Search
<form role="search" aria-label="Site search">
<input type="search" aria-label="Search query" />
<button type="submit">Search</button>
</form>
ARIA Labels
// aria-label for elements without visible text
<button aria-label="Close dialog">
<X size={24} />
</button>
// aria-labelledby to reference another element
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Action</h2>
<p>Are you sure you want to continue?</p>
</div>
// aria-describedby for additional description
<input
type="password"
aria-describedby="password-requirements"
/>
<p id="password-requirements">
Password must be at least 8 characters
</p>
ARIA States
// aria-expanded for expandable elements
<button
aria-expanded={isOpen}
aria-controls="dropdown-menu"
onClick={() => setIsOpen(!isOpen)}
>
Menu {isOpen ? <ChevronUp /> : <ChevronDown />}
</button>
<div id="dropdown-menu" hidden={!isOpen}>
{/* Dropdown content */}
</div>
// aria-pressed for toggle buttons
<button
aria-pressed={isPressed}
onClick={() => setIsPressed(!isPressed)}
>
{isPressed ? 'Pressed' : 'Not Pressed'}
</button>
// aria-selected for selectable items
<div role="tab" aria-selected={isActive}>
Tab 1
</div>
// aria-checked for checkboxes/radio buttons
<div
role="checkbox"
aria-checked={isChecked}
tabIndex={0}
onClick={() => setIsChecked(!isChecked)}
>
Custom Checkbox
</div>
ARIA Live Regions
// Announce changes to screen readers
<div
role="status"
aria-live="polite"
aria-atomic="true"
>
{statusMessage}
</div>
// For urgent announcements
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
>
{errorMessage}
</div>
// For form validation
<input
type="email"
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && (
<p id="email-error" role="alert">
Please enter a valid email address
</p>
)}
Color Contrast
Minimum Contrast Ratios (WCAG AA)
- Normal text: 4.5:1
- Large text (18pt+ or 14pt+ bold): 3:1
- UI components and graphics: 3:1
Good Contrast Examples
// High contrast text
<p className="text-slate-900 bg-white">
Great contrast (21:1)
</p>
<p className="text-slate-700 bg-white">
Good contrast (8:1)
</p>
// Button with good contrast
<button className="
bg-blue-600 text-white
hover:bg-blue-700
">
High Contrast Button (4.5:1)
</button>
Poor Contrast Examples (Avoid)
// ❌ Insufficient contrast
<p className="text-gray-400 bg-white">
Poor contrast (2.8:1) - fails WCAG AA
</p>
// ❌ Don't rely on color alone
<button className="bg-red-500 text-white">
Error Button (color alone indicates state)
</button>
// ✅ Better: Use icons + color
<button className="bg-red-500 text-white flex items-center gap-2">
<AlertCircle size={20} />
Error: Fix Issues
</button>
Tools for Checking Contrast
- Chrome DevTools: Inspect element → Accessibility tab
- Online: WebAIM Contrast Checker
- Figma: Stark plugin
Alternative Text
Images
// Informative images
<img
src="chart.png"
alt="Bar chart showing sales increased 40% in Q4 2025"
/>
// Decorative images
<img
src="decoration.png"
alt=""
role="presentation"
/>
// Functional images (buttons)
<button aria-label="Search">
<img src="search-icon.png" alt="" />
</button>
// Complex images
<figure>
<img
src="complex-diagram.png"
alt="System architecture diagram"
aria-describedby="diagram-description"
/>
<figcaption id="diagram-description">
Detailed description of the system architecture showing
three main components: frontend, API layer, and database.
The frontend communicates with the API via REST...
</figcaption>
</figure>
Icons
import { MagnifyingGlass, Bell, User } from '@phosphor-icons/react';
// Decorative icons (with adjacent text)
<button className="flex items-center gap-2">
<MagnifyingGlass aria-hidden="true" />
Search
</button>
// Functional icons (no adjacent text)
<button aria-label="Search">
<MagnifyingGlass />
</button>
// Icons with state
<button aria-label="Notifications (3 unread)">
<Bell />
<span className="sr-only">3 unread notifications</span>
<span aria-hidden="true" className="badge">3</span>
</button>
Forms
Labels and Instructions
// Always associate labels with inputs
<div>
<label htmlFor="email" className="block mb-1 font-medium">
Email Address
</label>
<input
id="email"
type="email"
required
aria-required="true"
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
// Group related inputs
<fieldset>
<legend className="font-medium mb-2">Contact Preferences</legend>
<div className="space-y-2">
<label className="flex items-center gap-2">
<input type="checkbox" name="email" />
Email
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="sms" />
SMS
</label>
</div>
</fieldset>
Error Handling
<div>
<label htmlFor="password" className="block mb-1 font-medium">
Password
</label>
<input
id="password"
type="password"
aria-invalid={hasError}
aria-describedby="password-requirements password-error"
className={`
w-full px-4 py-2 border rounded-lg
${hasError ? 'border-red-500' : 'border-slate-300'}
`}
/>
<p id="password-requirements" className="text-sm text-slate-600 mt-1">
Must be at least 8 characters
</p>
{hasError && (
<p id="password-error" role="alert" className="text-sm text-red-600 mt-1">
<AlertCircle className="inline" size={16} />
Password is too short
</p>
)}
</div>
Required Fields
// Indicate required fields clearly
<label htmlFor="name" className="block mb-1 font-medium">
Full Name
<span className="text-red-600" aria-label="required">*</span>
</label>
<input
id="name"
type="text"
required
aria-required="true"
className="w-full px-4 py-2 border rounded-lg"
/>
// Or use text
<label htmlFor="email" className="block mb-1 font-medium">
Email
<span className="text-sm font-normal text-slate-600">(required)</span>
</label>
Screen Reader-Only Content
sr-only Class
/* Add to your CSS */
.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;
}
.focus\:not-sr-only:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
Usage Examples
// Add context for screen readers
<button>
<Heart />
<span className="sr-only">Add to favorites</span>
</button>
// Provide additional context
<div>
<h2>Products</h2>
<span className="sr-only">Showing 24 of 100 results</span>
</div>
// Skip link
<a href="#main" className="sr-only focus:not-sr-only">
Skip to main content
</a>
Focus Indicators
Visible Focus States
// Default focus with ring
<button className="
px-4 py-2 rounded-lg
bg-blue-600 text-white
focus:outline-none
focus:ring-4 focus:ring-blue-500
focus:ring-offset-2
">
Click Me
</button>
// Custom focus style
<a
href="/page"
className="
underline
focus:outline-none
focus:ring-2 focus:ring-blue-500
focus:rounded
"
>
Link Text
</a>
// Focus within containers
<div className="
p-4 border border-slate-300 rounded-lg
focus-within:ring-4 focus-within:ring-blue-500
focus-within:border-blue-500
">
<input type="text" className="w-full focus:outline-none" />
</div>
Focus Management in Modals
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
// Store current focus
previousFocus.current = document.activeElement;
// Focus modal
modalRef.current?.focus();
// Trap focus within modal
const handleTab = (e) => {
if (e.key === 'Tab') {
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
} else {
// Restore focus
previousFocus.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center"
onClick={onClose}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className="bg-white rounded-lg p-6 max-w-md"
onClick={(e) => e.stopPropagation()}
>
{children}
<button
onClick={onClose}
className="mt-4 px-4 py-2 bg-slate-200 rounded-lg"
>
Close
</button>
</div>
</div>
);
}
Testing Checklist
Automated Testing
# Install axe-core for accessibility testing
npm install --save-dev @axe-core/react
# Use in tests
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('should have no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Manual Testing
Keyboard Navigation:
- Can navigate entire site using Tab key
- Can activate all interactive elements with Enter/Space
- Focus indicators are clearly visible
- No keyboard traps
- Logical tab order
Screen Reader Testing:
- Test with NVDA (Windows) or VoiceOver (Mac)
- All images have appropriate alt text
- Headings create logical structure
- Forms have proper labels
- Dynamic content is announced
Visual Testing:
- Text has sufficient contrast (4.5:1 minimum)
- UI works at 200% zoom
- Content reflows properly on mobile
- No information conveyed by color alone
- Focus indicators are visible
Tools to Use:
- Chrome DevTools Lighthouse
- WAVE browser extension
- axe DevTools browser extension
- Color contrast analyzer
- Screen reader (NVDA/VoiceOver)
Common Patterns
Accessible Modal
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg p-6 max-w-md">
<h2 id="modal-title" className="text-xl font-bold mb-2">
Confirm Action
</h2>
<p id="modal-description" className="text-slate-600 mb-4">
Are you sure you want to proceed?
</p>
<div className="flex gap-4">
<button
onClick={onConfirm}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Confirm
</button>
<button
onClick={onClose}
className="px-4 py-2 border border-slate-300 rounded-lg"
>
Cancel
</button>
</div>
</div>
</div>
Accessible Tabs
function Tabs({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={index}
role="tab"
aria-selected={activeTab === index}
aria-controls={`panel-${index}`}
id={`tab-${index}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
className={`
px-4 py-2 border-b-2
${activeTab === index
? 'border-blue-600 font-medium'
: 'border-transparent'
}
`}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeTab !== index}
className="p-4"
>
{tab.content}
</div>
))}
</div>
);
}
Accessible Tooltip
function Tooltip({ text, children }) {
const [isVisible, setIsVisible] = useState(false);
const tooltipId = useId();
return (
<div className="relative inline-block">
<button
aria-describedby={isVisible ? tooltipId : undefined}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</button>
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className="
absolute z-10 px-3 py-2
bg-slate-900 text-white text-sm
rounded-lg
bottom-full left-1/2 -translate-x-1/2 mb-2
"
>
{text}
</div>
)}
</div>
);
}