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>
11 KiB
Accessibility Deep Dive
Comprehensive WCAG 2.1 AA compliance checklist with practical implementation guidance.
Accessibility Philosophy
Accessibility isn't just compliance—it improves UX for everyone:
- Keyboard navigation helps power users
- Good contrast helps in bright sunlight
- Clear focus states help everyone understand what's selected
- Proper headings help screen readers AND SEO
WCAG 2.1 AA Checklist
Perceivable
Users must be able to perceive content.
1.1 Text Alternatives
All images need alt text:
<!-- Decorative image (no alt) -->
<img src="divider.svg" alt="" role="presentation">
<!-- Informative image -->
<img src="chart.png" alt="Sales increased 40% from Q1 to Q2">
<!-- Functional image (button/link) -->
<button>
<img src="search.svg" alt="Search">
</button>
<!-- Complex image (needs long description) -->
<figure>
<img src="diagram.png" alt="System architecture overview" aria-describedby="diagram-desc">
<figcaption id="diagram-desc">Detailed description of the system architecture...</figcaption>
</figure>
Icons:
<!-- Icon with visible text (icon is decorative) -->
<button>
<svg aria-hidden="true">...</svg>
<span>Settings</span>
</button>
<!-- Icon-only button -->
<button aria-label="Settings">
<svg aria-hidden="true">...</svg>
</button>
1.3 Adaptable
Semantic HTML structure:
<!-- Use semantic elements -->
<header>...</header>
<nav>...</nav>
<main>
<article>
<h1>Page Title</h1>
<section>
<h2>Section Title</h2>
</section>
</article>
</main>
<footer>...</footer>
<!-- Not this -->
<div class="header">...</div>
<div class="nav">...</div>
Heading hierarchy:
- One
<h1>per page - Don't skip levels (h1 → h3)
- Headings describe content structure
Form labels:
<!-- Explicit label -->
<label for="email">Email</label>
<input id="email" type="email">
<!-- Implicit label -->
<label>
Email
<input type="email">
</label>
<!-- Hidden label (for visual designs without labels) -->
<label for="search" class="sr-only">Search</label>
<input id="search" type="search" placeholder="Search...">
1.4 Distinguishable
Color contrast requirements:
| Content Type | Minimum Ratio | Tool |
|---|---|---|
| Normal text (<18px) | 4.5:1 | WebAIM Contrast Checker |
| Large text (≥18px or ≥14px bold) | 3:1 | |
| UI components & graphics | 3:1 |
Common contrast fixes:
/* Too light - fails */
.text-light { color: #9ca3af; } /* gray-400: 3.1:1 on white */
/* Passes AA */
.text-muted { color: #6b7280; } /* gray-500: 4.6:1 on white */
/* Passes AAA */
.text-strong { color: #374151; } /* gray-700: 9.1:1 on white */
Don't rely on color alone:
<!-- Bad: only color indicates error -->
<input class="border-red-500">
<!-- Good: color + icon + text -->
<input class="border-red-500" aria-invalid="true" aria-describedby="error">
<p id="error" class="text-red-600">
<svg aria-hidden="true">⚠️</svg>
Email is required
</p>
Text resize:
- Content must be readable at 200% zoom
- Use relative units (rem, em) not px for text
- Test by zooming browser to 200%
Operable
Users must be able to operate the interface.
2.1 Keyboard Accessible
All functionality must work with keyboard:
| Key | Expected Behavior |
|---|---|
| Tab | Move to next focusable element |
| Shift+Tab | Move to previous focusable element |
| Enter | Activate links, buttons |
| Space | Activate buttons, toggle checkboxes |
| Arrows | Navigate within components (tabs, menus, radios) |
| Escape | Close modals, dropdowns, cancel actions |
Focus must be visible:
/* Don't remove focus outlines */
:focus {
outline: none; /* ❌ Never do this without replacement */
}
/* Do provide visible focus */
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Or use ring utility */
.focusable:focus-visible {
@apply ring-2 ring-blue-500 ring-offset-2;
}
Keyboard traps:
- Modal dialogs should trap focus inside
- But must have a way to exit (Escape key, close button)
// Focus trap for modals
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef();
useEffect(() => {
if (isOpen) {
// Focus first focusable element
const firstFocusable = modalRef.current.querySelector('button, input, a');
firstFocusable?.focus();
// Trap focus inside
const handleTab = (e) => {
if (e.key === 'Tab') {
// ... trap logic
}
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}
}, [isOpen]);
// Return focus on close
}
Skip links:
<body>
<a href="#main-content" class="sr-only focus:not-sr-only">
Skip to main content
</a>
<nav>...</nav>
<main id="main-content">...</main>
</body>
2.4 Navigable
Page titles:
- Unique, descriptive page titles
- Format:
Page Name | Site Name
Focus order:
- Must follow logical reading order
- Don't use positive
tabindexvalues (messes up order) - Only use
tabindex="0"(make focusable) ortabindex="-1"(programmatically focusable)
Link purpose:
<!-- Bad -->
<a href="/article">Click here</a>
<a href="/article">Read more</a>
<!-- Good -->
<a href="/article">Read more about accessibility best practices</a>
<!-- Or with context -->
<a href="/article" aria-describedby="article-title">Read more</a>
Understandable
Users must be able to understand content and operation.
3.1 Readable
Language declaration:
<html lang="en">
<body>
<p>This is English.</p>
<p lang="fr">Ceci est français.</p>
</body>
</html>
3.2 Predictable
Consistent navigation:
- Same navigation in same location across pages
- Same elements behave the same way
No unexpected changes:
- Form inputs don't auto-submit on change
- No unexpected pop-ups
- Focus doesn't move unexpectedly
<!-- Bad: changes page on select -->
<select onchange="window.location = this.value">...</select>
<!-- Good: requires explicit action -->
<select id="region">...</select>
<button onclick="navigate()">Go</button>
3.3 Input Assistance
Error identification:
<label for="email">Email</label>
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
>
<p id="email-error" class="error">
Please enter a valid email address (e.g., name@example.com)
</p>
Required fields:
<label for="name">
Name <span aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input id="name" required aria-required="true">
Error prevention for critical actions:
- Confirm destructive actions
- Allow review before submission
- Provide undo capability
Robust
Content must work with current and future technologies.
4.1 Compatible
Valid HTML:
- Unique IDs
- Complete start/end tags
- Proper nesting
ARIA usage:
<!-- If you use ARIA, use it correctly -->
<!-- Roles -->
<div role="button" tabindex="0" onclick="...">Fake Button</div>
<!-- Better: just use <button> -->
<!-- States -->
<button aria-pressed="true">Bold</button>
<button aria-expanded="false" aria-controls="menu">Menu</button>
<!-- Live regions -->
<div aria-live="polite" aria-atomic="true">
<!-- Screen reader announces changes here -->
</div>
Focus Management
When to Manage Focus
| Scenario | Focus Action |
|---|---|
| Modal opens | Focus first element inside modal |
| Modal closes | Return focus to trigger element |
| Error occurs | Focus error message or first invalid field |
| New content loads | Focus heading or first new element |
| Item deleted | Focus previous/next item or container |
Implementation
// Store trigger reference
const triggerRef = useRef();
function openModal() {
triggerRef.current = document.activeElement;
setIsOpen(true);
}
function closeModal() {
setIsOpen(false);
// Return focus after state update
setTimeout(() => triggerRef.current?.focus(), 0);
}
Roving Tabindex (for component groups)
// Tab panels, menu items, radio groups
function Tabs({ tabs }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight') {
setActiveIndex((activeIndex + 1) % tabs.length);
}
if (e.key === 'ArrowLeft') {
setActiveIndex((activeIndex - 1 + tabs.length) % tabs.length);
}
};
return (
<div role="tablist" onKeyDown={handleKeyDown}>
{tabs.map((tab, i) => (
<button
key={tab.id}
role="tab"
tabIndex={i === activeIndex ? 0 : -1}
aria-selected={i === activeIndex}
ref={i === activeIndex ? (el) => el?.focus() : null}
>
{tab.label}
</button>
))}
</div>
);
}
Screen Reader Considerations
Announce Dynamic Changes
<!-- Live region for status messages -->
<div aria-live="polite" class="sr-only" id="status"></div>
<script>
function showSuccess(message) {
document.getElementById('status').textContent = message;
}
</script>
Hide Decorative Content
<!-- Hidden from screen readers -->
<svg aria-hidden="true">...</svg>
<span aria-hidden="true">•</span>
Provide Context
<!-- Ambiguous button -->
<button>Delete</button>
<!-- Clear button -->
<button aria-label="Delete comment by John">Delete</button>
<!-- Or use aria-describedby -->
<button aria-describedby="comment-123-author">Delete</button>
<span id="comment-123-author" class="sr-only">comment by John</span>
Testing Tools
Automated Testing
| Tool | What It Catches |
|---|---|
| axe DevTools | ~30% of WCAG issues |
| WAVE | Similar to axe, visual overlay |
| Lighthouse | Basic accessibility audit |
| ESLint a11y plugin | Catches issues in JSX |
Manual Testing Required
Automated tools miss ~70% of issues. Manual testing needed for:
- Keyboard navigation flow
- Screen reader experience
- Focus management
- Meaningful alt text
- Logical heading structure
Testing Checklist
- Navigate entire page with keyboard only
- Test with screen reader (VoiceOver, NVDA)
- Check color contrast with tool
- Zoom to 200% and verify usability
- Test with high contrast mode
- Verify focus indicators visible
- Check heading structure with outline tool
- Run axe DevTools audit
- Test forms with validation errors
Common Fixes Quick Reference
| Issue | Fix |
|---|---|
| Missing alt text | Add descriptive alt or alt="" for decorative |
| Low contrast | Use gray-600+ for text on white |
| Missing focus style | Add focus-visible ring/outline |
| Click-only interaction | Add keyboard handler + focusability |
| Missing form labels | Add <label> with for attribute |
| Heading skip | Use h1→h2→h3 in order |
| Color-only indicator | Add icon/text alongside color |
| Modal focus trap | Trap focus, allow Escape to close |
| Auto-playing media | Add pause control, don't autoplay |
| Motion | Respect prefers-reduced-motion |