Files
singular-particular-space/skills/controlled-ux-designer/ACCESSIBILITY.md
JL Kruger 5422131782 Initial commit — Singular Particular Space v1
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>
2026-03-27 12:09:22 +02:00

829 lines
17 KiB
Markdown

# 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:**
```tsx
<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>&copy; 2025 Company Name</p>
</footer>
```
**Bad:**
```tsx
<div class="header">
<div class="nav">
<div class="link">Home</div>
<div class="link">About</div>
</div>
</div>
```
### Heading Hierarchy
**Correct hierarchy:**
```tsx
<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):**
```tsx
<h1>Page Title</h1>
<h4>Section 1</h4> // ❌ Skips h2 and h3
```
## Keyboard Navigation
### Focus Management
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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)
```tsx
// ❌ 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
```tsx
// 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
```tsx
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
```tsx
// 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
```tsx
<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
```tsx
// 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
```css
/* 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
```tsx
// 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
```tsx
// 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
```tsx
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
```bash
# 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
```tsx
<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
```tsx
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
```tsx
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>
);
}
```
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/)
- [axe DevTools](https://www.deque.com/axe/devtools/)
- [WAVE Browser Extension](https://wave.webaim.org/extension/)