Accessibility
Visual Design
Color contrast, motion preferences, responsive accessibility, and typography
Visual Design
Accessible visual design ensures content is perceivable by users with visual impairments, motion sensitivities, and cognitive differences.
Color Contrast
WCAG Contrast Ratios
| Element | Level AA | Level AAA |
|---|---|---|
| Normal text (< 24px / < 18.66px bold) | 4.5:1 | 7:1 |
| Large text (≥ 24px / ≥ 18.66px bold) | 3:1 | 4.5:1 |
| UI components & graphical objects | 3:1 | Not defined |
| Non-text contrast (icons, borders) | 3:1 | Not defined |
Checking Contrast
// Calculate relative luminance (WCAG 2.x formula)
function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
// Calculate contrast ratio between two colors
function getContrastRatio(color1: [number, number, number], color2: [number, number, number]): number {
const l1 = getLuminance(...color1);
const l2 = getLuminance(...color2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Example: White text on blue background
getContrastRatio([255, 255, 255], [37, 99, 235]); // ~4.68:1 — passes AA for normal textColor-Independent Information
Never rely on color alone to convey information.
// Bad — color only indicates status
<span style={{ color: isError ? 'red' : 'green' }}>
{statusMessage}
</span>
// Good — color + icon + text
<span className={isError ? 'status-error' : 'status-success'}>
{isError ? <ErrorIcon aria-hidden="true" /> : <CheckIcon aria-hidden="true" />}
{statusMessage}
</span>
// Bad — chart legend using color only
// [Red] Errors [Blue] Warnings [Green] Success
// Good — color + pattern + label
// [Red ///] Errors [Blue ···] Warnings [Green ===] Success/* Form validation — don't rely on border color alone */
.input-error {
border-color: #dc2626;
border-width: 2px;
/* Also add an icon and text message */
}Dark Mode Considerations
/* Ensure contrast in both light and dark modes */
:root {
--text-primary: #1a1a1a; /* contrast 15.3:1 on white */
--text-secondary: #525252; /* contrast 7.1:1 on white */
--bg-primary: #ffffff;
}
[data-theme='dark'] {
--text-primary: #f5f5f5; /* contrast 16.0:1 on dark bg */
--text-secondary: #a3a3a3; /* contrast 5.3:1 on dark bg */
--bg-primary: #171717;
}
/* Avoid pure black on pure white — can be harsh for some users */
/* Use slightly off-white or dark gray instead */Motion and Animation
Respecting User Preferences
/* Reduce or remove animations for users who prefer reduced motion */
@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;
}
}
/* Define animations only for users who accept motion */
@media (prefers-reduced-motion: no-preference) {
.fade-in {
animation: fadeIn 0.3s ease-in;
}
.slide-up {
animation: slideUp 0.4s ease-out;
}
}// React hook for motion preference
function usePrefersReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
});
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// Usage — adapt animation based on preference
function AnimatedCard({ children }: CardProps) {
const prefersReduced = usePrefersReducedMotion();
return (
<div
className="card"
style={{
transition: prefersReduced ? 'none' : 'transform 0.3s ease',
}}
>
{children}
</div>
);
}Safe Animation Guidelines
| Guideline | Reason |
|---|---|
| Avoid flashing content > 3 times/sec | Can trigger seizures (WCAG 2.3.1) |
| Provide pause/stop controls for auto-playing content | Users need control over motion |
Use prefers-reduced-motion media query | Respect system preferences |
| Avoid parallax scrolling or provide alternative | Can cause vestibular discomfort |
| Keep transitions under 5 seconds | Long animations can be disorienting |
Typography and Readability
Readable Text
/* Accessible typography defaults */
body {
font-size: 1rem; /* 16px minimum base */
line-height: 1.5; /* WCAG 1.4.12: at least 1.5x font size */
letter-spacing: 0.12em; /* WCAG 1.4.12: at least 0.12x font size */
word-spacing: 0.16em; /* WCAG 1.4.12: at least 0.16x font size */
}
p {
max-width: 75ch; /* Optimal reading width: 45-75 characters */
margin-bottom: 1.5em; /* WCAG 1.4.12: paragraph spacing 2x font size */
}
/* Allow text resizing to 200% without horizontal scrolling */
/* Use relative units (rem, em, %) instead of px for text */
h1 { font-size: 2rem; } /* Not 32px */
h2 { font-size: 1.5rem; } /* Not 24px */
p { font-size: 1rem; } /* Not 16px */Text Spacing Override
WCAG 1.4.12 requires content to remain functional when users override text spacing.
/* Test with these overrides — content should not break */
/* Line height: 1.5x font size */
/* Paragraph spacing: 2x font size */
/* Letter spacing: 0.12x font size */
/* Word spacing: 0.16x font size */
/* Avoid fixed heights on text containers */
.card-description {
min-height: 60px; /* Good — allows expansion */
/* height: 60px; */ /* Bad — text may overflow when spacing changes */
overflow: visible; /* Don't clip overflowing text */
}Touch Target Size
WCAG 2.5.8 (Level AA) — Minimum 24×24px
/* Ensure touch targets meet minimum size */
button, a, [role="button"] {
min-width: 44px; /* 44px is recommended (iOS HIG / WCAG 2.5.5 AAA) */
min-height: 44px;
/* At minimum, 24×24px for WCAG 2.5.8 AA */
}
/* For inline links, use padding to increase target area */
nav a {
padding: 8px 12px;
display: inline-block;
}
/* Spacing between small targets */
.icon-button-group button {
min-width: 44px;
min-height: 44px;
margin: 4px; /* Prevents accidental activation of adjacent targets */
}Responsive Accessibility
Zoom and Reflow
/* WCAG 1.4.4: Content must be readable at 200% zoom */
/* WCAG 1.4.10: Content reflows at 320px viewport (400% zoom on 1280px) */
/* Use responsive layout that reflows naturally */
.layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
/* Don't use viewport units for font size without clamp */
/* Bad: font-size: 3vw; — text may become too small */
/* Good: */
h1 {
font-size: clamp(1.5rem, 2vw + 1rem, 3rem);
}
/* Ensure horizontal scrolling isn't required at 320px */
.content {
max-width: 100%;
overflow-wrap: break-word;
}Orientation
/* WCAG 1.3.4: Don't lock orientation unless essential */
/* Don't restrict display to portrait or landscape only */
/* If you must detect orientation, provide both layouts */
@media (orientation: portrait) {
.dashboard { flex-direction: column; }
}
@media (orientation: landscape) {
.dashboard { flex-direction: row; }
}High Contrast and Forced Colors
/* Support Windows High Contrast Mode */
@media (forced-colors: active) {
.button {
/* Custom colors are overridden — use system colors */
border: 2px solid ButtonText;
}
.custom-checkbox .checkmark {
/* Ensure custom checkmarks are visible */
forced-color-adjust: none;
background-color: Highlight;
}
/* Icons may need explicit coloring */
.icon {
fill: CanvasText;
}
}
/* System color keywords for forced-colors mode */
/* Canvas, CanvasText, LinkText, GrayText, Highlight, HighlightText, ButtonFace, ButtonText */Best Practices
Visual Design Guidelines
- Maintain 4.5:1 contrast ratio for normal text, 3:1 for large text
- Never convey information through color alone — add icons, text, or patterns
- Respect
prefers-reduced-motion— reduce or remove animations - Use relative units (
rem,em,%) for font sizes - Ensure content reflows without horizontal scrolling at 320px width
- Make touch targets at least 24×24px (44×44px recommended)
- Test with 200% browser zoom and text spacing overrides
- Support high contrast mode with
forced-colorsmedia query