Visual Accessibility: Color, Contrast, and Readability
Visual accessibility makes content perceivable - essential for users with visual impairments.
Quick Navigation: Color Contrast • Color Independence • Text Size & Readability • Motion & Animation • Focus Indicators & Interactive Elements • Testing & Tools
Quick Decision Guide
Visual Accessibility Essentials:
1. Color contrast: 4.5:1 minimum (AA), 7:1 enhanced (AAA) 2. Don't rely on color: Use icons/labels with color 3. Resizable text: Use rem/em, support 200% zoom 4. Readable fonts: 16px minimum, good line height 5. Reduced motion: Respect prefers-reduced-motion 6. Focus visible: Clear indicators for keyboard users
Tools: WebAIM Contrast Checker, axe DevTools, Lighthouse
Result: Usable by people with low vision, color blindness, and motion sensitivity.
Color Contrast
WCAG Standards
Level AA (Minimum):
- Normal text (<18pt): 4.5:1
- Large text (≥18pt or ≥14pt bold): 3:1
- UI components: 3:1
Level AAA (Enhanced):
- Normal text: 7:1
- Large text: 4.5:1Examples
/* ❌ Fail: 2.1:1 contrast */
.text {
color: #999; /* Light gray */
background: #fff; /* White */
}
/* ⚠️ Pass AA (4.6:1) but not AAA */
.text {
color: #757575; /* Medium gray */
background: #fff;
}
/* ✅ Pass AAA (7.5:1) */
.text {
color: #333; /* Dark gray */
background: #fff;
}
/* ✅ Pass AA (4.9:1) */
.text {
color: #fff;
background: #0070f3; /* Blue */
}Check All States
.button {
color: #fff;
background: #0070f3; /* Check: 4.5:1 ✅ */
}
.button:hover {
background: #0051cc; /* Check: Still 4.5:1 ✅ */
}
.button:disabled {
color: #666;
background: #ccc; /* Check: 3.5:1 ❌ - Not enough! */
}
/* Fix disabled state */
.button:disabled {
color: #333; /* Darker text */
background: #e0e0e0; /* Lighter background */
/* Now: 4.7:1 ✅ */
}Tools
Color Independence
Don't Rely on Color Alone
// ❌ Bad: Color only
<div>
<span style={{ color: 'red' }}>Error</span>
<span style={{ color: 'green' }}>Success</span>
</div>
// ✅ Good: Icon + text + color
<div>
<span style={{ color: 'red' }}>
<ErrorIcon /> Error: Invalid input
</span>
<span style={{ color: 'green' }}>
<CheckIcon /> Success: Saved
</span>
</div>
// ❌ Bad: Color-coded chart only
<PieChart
data={[
{ value: 30, color: 'red' },
{ value: 70, color: 'green' }
]}
/>
// ✅ Good: Labels + patterns + color
<PieChart
data={[
{ value: 30, color: 'red', label: 'Failed: 30%', pattern: 'diagonal' },
{ value: 70, color: 'green', label: 'Passed: 70%', pattern: 'solid' }
]}
/>Form Validation
// ❌ Bad: Red border only
<input
className={error ? 'border-red-500' : 'border-gray-300'}
/>
// ✅ Good: Border + icon + text
<div>
<input
className={error ? 'border-red-500' : 'border-gray-300'}
aria-invalid={!!error}
aria-describedby={error ? 'error-msg' : undefined}
/>
{error && (
<div id="error-msg" role="alert" className="text-red-500">
<ErrorIcon /> {error}
</div>
)}
</div>Links
/* ❌ Bad: Color only, no underline */
a {
color: blue;
text-decoration: none;
}
/* ✅ Good: Underline or other visual indicator */
a {
color: blue;
text-decoration: underline;
}
/* Or use another indicator on hover/focus */
a {
color: blue;
text-decoration: none;
border-bottom: 2px solid transparent;
}
a:hover,
a:focus {
border-bottom-color: blue;
}Text Size & Readability
Font Size
/* ✅ Use relative units */
body {
font-size: 16px; /* Base size (1rem) */
}
h1 { font-size: 2rem; } /* 32px */
h2 { font-size: 1.5rem; } /* 24px */
h3 { font-size: 1.25rem; } /* 20px */
p { font-size: 1rem; } /* 16px */
small { font-size: 0.875rem; } /* 14px */
/* ❌ Don't use fixed pixels for text */
.text {
font-size: 14px; /* Won't scale with user preferences */
}Line Height & Spacing
/* ✅ Good readability */
p {
font-size: 1rem;
line-height: 1.5; /* 150% of font size */
max-width: 65ch; /* 65 characters per line */
margin-bottom: 1em;
}
/* ❌ Too tight */
p {
line-height: 1; /* Hard to read */
}
/* ✅ Spacing for lists */
ul, ol {
line-height: 1.5;
}
li {
margin-bottom: 0.5em;
}Support Text Zoom
/* ❌ Fixed heights break on zoom */
.container {
height: 400px;
overflow: hidden; /* Text gets cut off */
}
/* ✅ Flexible heights */
.container {
min-height: 400px;
height: auto;
}
/* ❌ Fixed widths break layout */
.sidebar {
width: 200px;
}
/* ✅ Use responsive units */
.sidebar {
width: min(200px, 30vw);
min-width: 150px;
}Font Choices
/* ✅ Readable font families */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Inter', 'Helvetica', 'Arial', sans-serif;
}
/* Avoid: */
/* - Decorative fonts for body text */
/* - All caps for long text */
/* - Low contrast fonts */Motion & Animation
Respect User Preferences
/* Reduce motion for sensitive users */
@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;
}
}Safe Animation Patterns
// Check user preference
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
// Conditional animation
function AnimatedComponent() {
return (
<motion.div
initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.3
}}
>
Content
</motion.div>
);
}
// Or disable entirely
function Component() {
if (prefersReducedMotion) {
return <div>Content</div>;
}
return <AnimatedDiv>Content</AnimatedDiv>;
}Auto-Playing Content
// ❌ Bad: Auto-play video
<video src="video.mp4" autoPlay />
// ✅ Good: User-initiated
<video src="video.mp4" controls />
// ✅ Good: Pause button if auto-playing
function VideoPlayer() {
const [playing, setPlaying] = useState(!prefersReducedMotion);
return (
<div>
<video
src="video.mp4"
autoPlay={playing}
muted
/>
<button onClick={() => setPlaying(!playing)}>
{playing ? 'Pause' : 'Play'}
</button>
</div>
);
}Avoid Seizure Triggers
// Warning before flashing content
<div>
<div role="alert">
⚠️ Warning: This video contains flashing lights
</div>
<button onClick={() => setShowVideo(true)}>
I understand, show video
</button>
</div>Focus Indicators & Interactive Elements
Visible Focus
/* ❌ Never remove focus outline */
button:focus {
outline: none; /* NEVER! */
}
/* ✅ Custom but visible focus */
button:focus-visible {
outline: 2px solid #0070f3;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
button:focus-visible {
outline: 3px solid currentColor;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
button:focus-visible {
outline-color: #66b3ff;
}
}Touch Targets
/* ✅ Minimum 44x44px touch targets */
button,
a {
min-width: 44px;
min-height: 44px;
padding: 0.5rem 1rem;
}
/* Ensure spacing between targets */
.button-group button {
margin-right: 0.5rem;
}Hover & Focus States
/* ✅ Visible states */
button {
background: #0070f3;
transition: background 0.2s;
}
button:hover {
background: #0051cc;
}
button:focus-visible {
outline: 2px solid #0070f3;
outline-offset: 2px;
}
button:active {
background: #003d99;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
opacity: 0.6;
}Testing & Tools
Browser DevTools
Chrome DevTools:
1. Lighthouse audit (Accessibility score)
2. Inspect > Color picker (shows contrast ratio)
3. Rendering tab > Emulate vision deficiencies
Firefox:
Browser Extensions
Manual Testing
✅ Zoom to 200%
✅ Test with different color schemes
✅ Simulate color blindness
✅ Keyboard navigation
Common Issues Checklist
❌ Low color contrast
❌ Text not resizable
❌ Color as only indicator
❌ No focus indicator
❌ Auto-playing animations
❌ Small touch targets
❌ Fixed text sizes
❌ Missing alt text