Building Accessible Web Applications: A Practical Guide
Web accessibility isn't just about compliance—it's about creating inclusive experiences that work for everyone. After auditing and improving accessibility for several projects, I've learned that accessibility improvements often make applications better for all users, not just those with disabilities.
Why Accessibility Matters
The Business Case
Personal Impact
Working on accessibility has made me a better developer. It forces you to think about edge cases, consider different user contexts, and write more semantic code.
Foundation: Semantic HTML
The most important accessibility improvement is using proper HTML elements.
Before and After
<!-- Bad: Using divs for everything -->
<div class="button" onclick="handleClick()">Click Me</div>
<div class="heading">Page Title</div>
<div class="input-container">
<div class="label">Email</div>
<div class="input" contenteditable="true"></div>
</div>
<!-- Good: Using semantic elements -->
<button type="button" onclick="handleClick()">Click Me</button>
<h1>Page Title</h1>
<div class="input-container">
<label for="email">Email</label>
<input type="email" id="email" name="email" />
</div>
Form Accessibility
Forms are where accessibility really matters:
<form>
<div class="form-group">
<label for="email">
Email Address
<span class="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-error email-help"
aria-invalid="false"
/>
<div id="email-help" class="help-text">
We'll never share your email with anyone else.
</div>
<div id="email-error" class="error-message" role="alert">
<!-- Error message appears here -->
</div>
</div>
<fieldset>
<legend>Preferred Contact Method</legend>
<div class="radio-group">
<input type="radio" id="contact-email" name="contact" value="email" />
<label for="contact-email">Email</label>
</div>
<div class="radio-group">
<input type="radio" id="contact-phone" name="contact" value="phone" />
<label for="contact-phone">Phone</label>
</div>
</fieldset>
</form>
ARIA: When Semantic HTML Isn't Enough
ARIA (Accessible Rich Internet Applications) attributes help when you need to go beyond standard HTML elements.
Custom Components
Here's how I made a custom dropdown accessible:
function AccessibleDropdown({ options, value, onChange, label }) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const buttonRef = useRef(null);
const listRef = useRef(null);
const handleKeyDown = (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex(prev =>
prev < options.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
event.preventDefault();
if (isOpen) {
setFocusedIndex(prev =>
prev > 0 ? prev - 1 : options.length - 1
);
}
break;
case 'Enter':
case ' ':
event.preventDefault();
if (isOpen && focusedIndex >= 0) {
onChange(options[focusedIndex]);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(!isOpen);
}
break;
case 'Escape':
setIsOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div className="dropdown">
<button
ref={buttonRef}
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
className="dropdown-button"
>
{value || 'Select an option'}
</button>
<div id="dropdown-label" className="dropdown-label">
{label}
</div>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
className="dropdown-list"
>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={option.value === value}
className={`dropdown-option ${
index === focusedIndex ? 'focused' : ''
}`}
onClick={() => {
onChange(option);
setIsOpen(false);
buttonRef.current?.focus();
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
Live Regions
For dynamic content updates:
function StatusMessage({ message, type = 'polite' }) {
return (
<div
role="status"
aria-live={type}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}
// Usage
function FormWithValidation() {
const [status, setStatus] = useState('');
const handleSubmit = async (formData) => {
setStatus('Submitting form...');
try {
await submitForm(formData);
setStatus('Form submitted successfully!');
} catch (error) {
setStatus('Error submitting form. Please try again.');
}
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<StatusMessage message={status} />
</form>
);
}
Keyboard Navigation
Ensuring your application works with keyboard-only navigation is crucial.
Focus Management
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Store previously focused element
previousFocusRef.current = document.activeElement;
// Focus first focusable element in modal
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
// Trap focus within modal
const handleKeyDown = (event) => {
if (event.key === 'Tab') {
const focusableElements = Array.from(
modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
} else if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen && previousFocusRef.current) {
// Return focus to previously focused element
previousFocusRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
className="modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
Skip Links
Skip links help keyboard users navigate quickly:
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
transition: top 0.3s;
}
.skip-link:focus {
top: 0;
}
<a href="#main-content" class="skip-link">Skip to main content</a>
<a href="#navigation" class="skip-link">Skip to navigation</a>
Color and Contrast
Proper color contrast is essential for users with visual impairments.
Contrast Requirements
CSS for Better Contrast
:root {
/* Ensure sufficient contrast ratios */
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--accent: #2563eb;
--accent-hover: #1d4ed8;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
:root {
--text-primary: #000000;
--text-secondary: #000000;
--bg-primary: #ffffff;
--accent: #0000ff;
}
}
/* Don't rely on color alone */
.status-success {
color: #059669;
}
.status-success::before {
content: "✓ ";
font-weight: bold;
}
.status-error {
color: #dc2626;
}
.status-error::before {
content: "✗ ";
font-weight: bold;
}
Focus Indicators
/* Custom focus styles */
.btn:focus,
.form-input:focus,
.link:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
border-radius: 4px;
}
/* Focus within for complex components */
.card:focus-within {
box-shadow: 0 0 0 2px #2563eb;
}
Images and Media
Alternative Text
<!-- Informative images -->
<img src="chart.png" alt="Sales increased 25% from Q1 to Q2" />
<!-- Decorative images -->
<img src="decoration.png" alt="" />
<!-- Complex images -->
<img src="complex-chart.png" alt="Quarterly sales data"
aria-describedby="chart-description" />
<div id="chart-description">
<p>Detailed breakdown of quarterly sales showing...</p>
</div>
Video Accessibility
<video controls>
<source src="video.mp4" type="video/mp4" />
<track kind="captions" src="captions.vtt" srclang="en"
label="English" default />
<track kind="descriptions" src="descriptions.vtt" srclang="en"
label="English descriptions" />
<p>Your browser doesn't support HTML video.</p>
</video>
Testing Your Accessibility
Automated Testing
// Using @axe-core/react
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('should not have accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Manual Testing Checklist
- Tab through all interactive elements
- Verify focus is visible and logical
- Test with screen reader navigation
- Use NVDA (Windows), VoiceOver (Mac), or ORCA (Linux)
- Verify content is announced correctly
- Check landmark navigation
- Use WebAIM's contrast checker
- Test with high contrast mode
- Verify information isn't conveyed by color alone
Performance and Accessibility
Reduce Motion
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Lazy Loading with Accessibility
function AccessibleImage({ src, alt, ...props }) {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
return (
<div className="image-container">
{!isLoaded && !hasError && (
<div className="image-placeholder" aria-label="Loading image">
<span className="spinner" aria-hidden="true"></span>
</div>
)}
<img
src={src}
alt={alt}
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
style={{ opacity: isLoaded ? 1 : 0 }}
{...props}
/>
{hasError && (
<div className="error-state" role="img" aria-label={alt}>
<span>Failed to load image: {alt}</span>
</div>
)}
</div>
);
}
Tools and Resources
Helpful Tools
Testing Strategy
Conclusion
Accessibility is not a feature you add at the end—it's a fundamental part of good web development. By following these practices, you'll create applications that work for everyone and often perform better overall.
Key takeaways:
Remember: accessibility benefits everyone, not just people with disabilities. A well-designed accessible interface is simply a well-designed interface.
The investment in learning accessibility pays dividends in code quality, user satisfaction, and professional growth. Your users will thank you for it.