Color Contrast and Visual Design (WCAG Guidelines)
1. Color Contrast Ratio (WCAG AA 4.5:1, AAA 7:1) Calculations
| Element Type | WCAG Level AA | WCAG Level AAA | Calculation Method |
|---|---|---|---|
| Normal Text | 4.5:1 minimum | 7:1 minimum | Relative luminance ratio |
| Large Text (>18pt or 14pt bold) | 3:1 minimum | 4.5:1 minimum | Relative luminance ratio |
| UI Components (buttons, form borders) | 3:1 minimum | N/A | Against adjacent colors |
| Graphical Objects (icons, chart elements) | 3:1 minimum | N/A | Against background |
| Focus Indicators WCAG 2.2 | 3:1 minimum | N/A | Against background + unfocused state |
| Disabled Elements | No requirement | No requirement | Best practice: maintain 3:1 |
Example: Color contrast calculations and fixes
<!-- Poor contrast (FAIL) -->
<p style="color: #777; background: #fff;">
This text has 4.47:1 contrast - FAILS AA for normal text
</p>
<!-- Fixed contrast (PASS AA) -->
<p style="color: #595959; background: #fff;">
This text has 7.0:1 contrast - PASSES AA and AAA
</p>
<!-- Large text (18pt+) with lower contrast OK -->
<h2 style="color: #767676; background: #fff; font-size: 24px;">
Large text with 3.01:1 contrast - PASSES AA for large text
</h2>
<!-- Button with sufficient contrast -->
<button style="
background: #0066cc;
color: #fff;
border: 2px solid #004499;">
Text: 8.59:1 (PASS), Border: 5.14:1 against background (PASS)
</button>
<!-- Focus indicator with sufficient contrast -->
<style>
.accessible-link:focus {
outline: 3px solid #0066cc; /* 3.02:1 against white background */
outline-offset: 2px;
}
</style>
<a href="#" class="accessible-link">Accessible focus indicator</a>
<!-- Icon with sufficient contrast -->
<svg style="fill: #595959;" role="img" aria-label="Settings">
<!-- Icon with 7:1 contrast against white background -->
<path d="M..."></path>
</svg>
| Contrast Ratio | Passes For | Visual Example |
|---|---|---|
| 21:1 | Maximum possible (black on white) | Perfect contrast |
| 7:1+ | AAA normal text, AA large text | Excellent readability |
| 4.5:1 - 7:1 | AA normal text | Good readability |
| 3:1 - 4.5:1 | AA large text, UI components | Acceptable for large text/UI |
| <3:1 | Fails all requirements | Poor contrast |
Example: JavaScript contrast checker
// Calculate contrast ratio between two colors
function getContrastRatio(color1, color2) {
const l1 = getRelativeLuminance(color1);
const l2 = getRelativeLuminance(color2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Calculate relative luminance
function getRelativeLuminance(hexColor) {
// Convert hex to RGB
const rgb = hexToRgb(hexColor);
// Convert RGB to sRGB
const srgb = rgb.map(val => {
val = val / 255;
return val <= 0.03928
? val / 12.92
: Math.pow((val + 0.055) / 1.055, 2.4);
});
// Calculate luminance
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
] : null;
}
// Check WCAG compliance
function checkWCAG(foreground, background, fontSize, isBold) {
const ratio = getContrastRatio(foreground, background);
const isLargeText = fontSize >= 18 || (fontSize >= 14 && isBold);
const aaThreshold = isLargeText ? 3 : 4.5;
const aaaThreshold = isLargeText ? 4.5 : 7;
return {
ratio: ratio.toFixed(2),
passAA: ratio >= aaThreshold,
passAAA: ratio >= aaaThreshold,
isLargeText
};
}
// Usage
const result = checkWCAG('#595959', '#ffffff', 16, false);
console.log(`Contrast: ${result.ratio}:1`);
console.log(`AA: ${result.passAA ? 'PASS' : 'FAIL'}`);
console.log(`AAA: ${result.passAAA ? 'PASS' : 'FAIL'}`);
| Tool | Type | Features | URL |
|---|---|---|---|
| WebAIM Contrast Checker | Online | Simple, accurate, shows pass/fail | webaim.org/resources/contrastchecker/ |
| Contrast Ratio (Lea Verou) | Online | Real-time, color picker | contrast-ratio.com |
| Colour Contrast Analyser | Desktop app | Eyedropper, simulations | TPGi CCA (free) |
| Chrome DevTools | Browser | Built-in, suggests colors | Chrome DevTools > Elements > Color picker |
| WAVE Extension | Browser | Full page analysis | wave.webaim.org/extension/ |
| Stark Plugin | Design tool | Figma/Sketch integration | getstark.co |
Note: Always test contrast on actual backgrounds. Overlays, gradients, and images can affect
perceived contrast even if colors pass calculation.
2. Color Blind Accessibility (Red-Green, Blue-Yellow) Patterns
| Color Vision Deficiency | Prevalence | Colors Affected | Design Impact |
|---|---|---|---|
| Protanopia (Red-blind) | ~1% males, 0.01% females | Red appears dark, confused with green | Red/green combinations problematic |
| Deuteranopia (Green-blind) | ~1% males, 0.01% females | Green appears beige, confused with red | Red/green combinations problematic |
| Tritanopia (Blue-blind) | ~0.001% all | Blue/yellow confusion | Blue/yellow combinations problematic |
| Achromatopsia (Total color blindness) | ~0.003% all | No color perception (grayscale) | All color-only indicators fail |
Example: Color blind friendly patterns
<!-- Bad: Color only for status -->
<span style="color: red;">Error</span>
<span style="color: green;">Success</span>
<!-- Good: Color + icon + text -->
<span class="status-error">
<svg aria-hidden="true" class="icon">...error icon...</svg>
Error: File not found
</span>
<span class="status-success">
<svg aria-hidden="true" class="icon">...checkmark...</svg>
Success: File uploaded
</span>
<!-- Bad: Color-only link distinction -->
<p style="color: #000;">
Visit our <a href="#" style="color: #00f;">website</a> for more info.
</p>
<!-- Good: Underline + color -->
<p>
Visit our <a href="#" style="color: #0066cc; text-decoration: underline;">
website
</a> for more info.
</p>
<!-- Good: Form validation with multiple cues -->
<style>
.input-error {
border: 2px solid #d32f2f; /* Color */
border-left: 5px solid #d32f2f; /* Extra visual cue */
background: #ffebee; /* Background tint */
background-image: url('error-icon.svg'); /* Icon */
background-position: right 8px center;
background-repeat: no-repeat;
}
</style>
<input
type="email"
class="input-error"
aria-invalid="true"
aria-describedby="email-error">
<span id="email-error" class="error-text">
⚠ Please enter a valid email address
</span>
<!-- Chart with patterns + colors -->
<style>
.chart-bar-1 {
fill: #0066cc;
stroke-dasharray: none;
}
.chart-bar-2 {
fill: #00897b;
stroke-dasharray: 5,5; /* Stripe pattern */
}
.chart-bar-3 {
fill: #f57c00;
stroke-dasharray: 2,2; /* Dot pattern */
}
</style>
| Design Strategy | Implementation | Example Use |
|---|---|---|
| Use Patterns/Textures | Add stripes, dots, hatching to colored areas | Chart bars, map regions |
| Add Icons/Symbols | Include visual symbols with color | Status indicators (✓, ✗, ⚠) |
| Use Shape Differences | Different shapes for categories | Chart markers (●, ■, ▲) |
| Add Text Labels | Label all colored elements | Chart legends, graph labels |
| Underline Links | Don't rely on color alone for links | Body text links |
| Sufficient Brightness Contrast | Ensure luminance difference even if hue fails | All color combinations |
| Position/Order Cues | Use consistent positioning for status | Traffic lights (red=top, green=bottom) |
Example: Color blind friendly color palettes
<!-- Safe color combinations (work for most CVD types) -->
<style>
:root {
/* Blue-Orange palette (deuteranopia/protanopia safe) */
--color-primary: #0077bb; /* Blue */
--color-secondary: #ee7733; /* Orange */
--color-accent: #009988; /* Teal */
/* High contrast palette */
--color-dark: #332288; /* Dark purple */
--color-medium: #44aa99; /* Teal */
--color-light: #ddcc77; /* Tan */
/* Status colors with sufficient difference */
--color-success: #117733; /* Dark green */
--color-warning: #ddaa33; /* Gold */
--color-error: #cc3311; /* Red */
--color-info: #0077bb; /* Blue */
/* Avoid these combinations */
/* Red + Green (common CVD issue) */
/* Light green + Yellow */
/* Blue + Purple (for some types) */
}
/* Sequential data with brightness steps */
.data-1 { background: #f7fbff; } /* Lightest */
.data-2 { background: #deebf7; }
.data-3 { background: #c6dbef; }
.data-4 { background: #9ecae1; }
.data-5 { background: #6baed6; }
.data-6 { background: #4292c6; }
.data-7 { background: #2171b5; }
.data-8 { background: #08519c; }
.data-9 { background: #08306b; } /* Darkest */
</style>
Warning: Never use color as the only visual means of conveying
information. Always provide additional cues like text, icons, patterns, or positional differences.
3. High Contrast Mode (Windows, prefers-contrast) Support
| High Contrast Feature | OS/Browser | CSS Detection | Design Impact |
|---|---|---|---|
| Windows High Contrast | Windows 10/11 | @media (prefers-contrast: high) |
Overrides all colors with system palette |
| Increased Contrast | macOS, iOS | @media (prefers-contrast: more) |
Suggests stronger contrast |
| Forced Colors | Windows, browsers | @media (forced-colors: active) |
Limited color palette enforced |
| Inverted Colors | macOS, iOS | @media (inverted-colors: inverted) |
All colors inverted |
Example: High contrast mode support
<!-- Default styles -->
<style>
.button {
background: #0066cc;
color: white;
border: 2px solid #0066cc;
padding: 12px 24px;
}
/* High contrast mode adjustments */
@media (prefers-contrast: high) {
.button {
border-width: 3px; /* Thicker borders */
font-weight: 600; /* Bolder text */
}
/* Ensure focus indicators are extra visible */
.button:focus {
outline: 4px solid;
outline-offset: 4px;
}
}
/* Forced colors mode (Windows High Contrast) */
@media (forced-colors: active) {
.button {
/* Use system colors instead of custom colors */
background: ButtonFace;
color: ButtonText;
border-color: ButtonText;
}
.button:hover {
background: Highlight;
color: HighlightText;
border-color: Highlight;
}
.button:disabled {
color: GrayText;
border-color: GrayText;
}
/* Preserve important visual distinctions */
.icon {
/* Force icon to be visible */
forced-color-adjust: auto;
}
/* Custom focus indicators */
.button:focus {
outline: 2px solid ButtonText;
outline-offset: 2px;
}
}
/* Inverted colors support */
@media (inverted-colors: inverted) {
/* Re-invert images/media so they appear normal */
img, video {
filter: invert(1);
}
}
/* Increased contrast (macOS/iOS) */
@media (prefers-contrast: more) {
body {
/* Slightly increase base contrast */
}
.text-secondary {
/* Make secondary text darker */
color: #444; /* Instead of #666 */
}
}
</style>
| System Color Keyword | Use Case | Example |
|---|---|---|
| ButtonFace | Button background | background: ButtonFace; |
| ButtonText | Button text, borders | color: ButtonText; |
| Canvas | Page background | background: Canvas; |
| CanvasText | Body text | color: CanvasText; |
| LinkText | Hyperlinks | color: LinkText; |
| Highlight | Selected/active background | background: Highlight; |
| HighlightText | Selected text | color: HighlightText; |
| GrayText | Disabled text | color: GrayText; |
| Field | Input background | background: Field; |
| FieldText | Input text | color: FieldText; |
Note: In forced colors mode, custom colors are overridden. Use
forced-color-adjust: none sparingly and only when necessary to preserve critical visual
information.
4. Visual Indicators Beyond Color
| Indicator Type | Visual Cue | Use Case | Implementation |
|---|---|---|---|
| Icons | ✓ ✗ ⚠ ℹ ★ | Status, ratings, actions | SVG or icon fonts with ARIA labels |
| Text Labels | "Success", "Error", "Required" | All important states | Explicit text with semantic markup |
| Underlines | Solid, dashed, dotted, double | Links, emphasis, errors | text-decoration |
| Borders | Thickness, style variations | Focus, active state, errors | border with different widths/styles |
| Patterns/Textures | Stripes, dots, crosshatch | Charts, graphs, maps | SVG patterns or CSS gradients |
| Size/Weight | Font size/weight changes | Hierarchy, emphasis | font-size, font-weight |
| Position/Layout | Spatial arrangement | Navigation state, progress | Flexbox/Grid positioning |
| Shape | Circle, square, triangle | Chart markers, bullets | SVG or CSS shapes |
Example: Multi-sensory status indicators
<!-- Status with icon + color + text -->
<div class="alert alert-success">
<svg aria-hidden="true" class="alert-icon">
<use href="#checkmark-icon"></use>
</svg>
<strong>Success:</strong> Your changes have been saved.
</div>
<div class="alert alert-error">
<svg aria-hidden="true" class="alert-icon">
<use href="#error-icon"></use>
</svg>
<strong>Error:</strong> Unable to connect to server.
</div>
<div class="alert alert-warning">
<svg aria-hidden="true" class="alert-icon">
<use href="#warning-icon"></use>
</svg>
<strong>Warning:</strong> Your session will expire in 5 minutes.
</div>
<style>
.alert {
padding: 12px 16px;
border-left: 5px solid; /* Thick left border */
display: flex;
align-items: flex-start;
gap: 12px;
}
.alert-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
}
.alert-success {
background: #e8f5e9;
border-color: #2e7d32;
color: #1b5e20;
}
.alert-success .alert-icon {
fill: #2e7d32;
}
.alert-error {
background: #ffebee;
border-color: #c62828;
color: #b71c1c;
}
.alert-error .alert-icon {
fill: #c62828;
}
.alert-warning {
background: #fff3e0;
border-color: #f57c00;
color: #e65100;
}
.alert-warning .alert-icon {
fill: #f57c00;
}
</style>
<!-- Required field indicators -->
<label for="email">
Email Address
<abbr
title="required"
aria-label="required"
style="color: #d32f2f; text-decoration: none; font-weight: bold;">
*
</abbr>
<span class="sr-only">(required)</span>
</label>
<!-- Link with underline (not just color) -->
<p>
Read our <a href="/privacy" style="
color: #0066cc;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 2px;">privacy policy</a> for details.
</p>
<!-- Progress indicator with multiple cues -->
<div class="progress-steps">
<div class="step step-completed">
<span class="step-number">1</span>
<svg class="step-icon" aria-hidden="true">✓</svg>
<span class="step-label">Personal Info</span>
</div>
<div class="step step-active">
<span class="step-number">2</span>
<span class="step-label">Payment</span>
</div>
<div class="step step-upcoming">
<span class="step-number">3</span>
<span class="step-label">Confirmation</span>
</div>
</div>
Warning: WCAG Success Criterion 1.4.1 requires that color is not used as the only visual means of conveying information. Always combine color with at least one
other visual differentiator.
5. CSS Custom Properties (--color-primary) for Theming
| Theme Aspect | Custom Property Pattern | Use Case |
|---|---|---|
| Colors | --color-primary, --color-text |
Brand colors, semantic colors |
| Spacing | --space-sm, --space-md |
Consistent spacing scale |
| Typography | --font-size-base, --line-height |
Font sizes, line heights |
| Shadows | --shadow-sm, --shadow-md |
Elevation levels |
| Borders | --border-radius, --border-width |
Consistent borders |
| Transitions | --transition-fast, --transition-slow |
Animation timing |
Example: Accessible theming system with CSS custom properties
<!-- Theme system with light/dark modes -->
<style>
:root {
/* Light theme (default) */
--color-background: #ffffff;
--color-surface: #f5f5f5;
--color-text: #212121;
--color-text-secondary: #757575;
--color-primary: #1976d2;
--color-primary-dark: #1565c0;
--color-error: #d32f2f;
--color-success: #388e3c;
--color-warning: #f57c00;
/* Ensure minimum contrast ratios */
--color-link: #0066cc; /* 4.5:1 on white */
--color-border: #e0e0e0;
/* Typography */
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-sm: 14px;
--line-height-base: 1.5;
--line-height-heading: 1.2;
/* Spacing scale */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Accessible focus indicator */
--focus-ring: 2px solid var(--color-primary);
--focus-offset: 2px;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--color-background: #121212;
--color-surface: #1e1e1e;
--color-text: #e0e0e0;
--color-text-secondary: #b0b0b0;
--color-primary: #90caf9;
--color-primary-dark: #64b5f6;
--color-error: #ef5350;
--color-success: #66bb6a;
--color-warning: #ffa726;
--color-link: #90caf9; /* 7:1 on dark background */
--color-border: #333333;
}
}
/* High contrast mode adjustments */
@media (prefers-contrast: high) {
:root {
--color-text: #000000;
--color-background: #ffffff;
--color-border: #000000;
--focus-ring: 3px solid;
--focus-offset: 3px;
}
}
/* Manual theme toggle support */
[data-theme="dark"] {
--color-background: #121212;
--color-surface: #1e1e1e;
--color-text: #e0e0e0;
--color-text-secondary: #b0b0b0;
/* ... dark theme colors ... */
}
[data-theme="high-contrast"] {
--color-background: #000000;
--color-text: #ffffff;
--color-primary: #ffff00;
--color-link: #00ffff;
/* Maximum contrast colors */
}
/* Apply theme variables */
body {
background: var(--color-background);
color: var(--color-text);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
a {
color: var(--color-link);
text-decoration: underline;
}
button {
background: var(--color-primary);
color: var(--color-background);
border: none;
padding: var(--space-sm) var(--space-md);
}
button:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
padding: var(--space-md);
}
</style>
<!-- Theme toggle -->
<button id="theme-toggle" aria-label="Toggle dark mode">
<svg aria-hidden="true">...moon/sun icon...</svg>
</button>
<script>
const themeToggle = document.getElementById('theme-toggle');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
// Get stored theme or use system preference
function getTheme() {
const stored = localStorage.getItem('theme');
if (stored) return stored;
return prefersDark.matches ? 'dark' : 'light';
}
// Apply theme
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
themeToggle.setAttribute('aria-label',
theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
);
}
// Initialize theme
setTheme(getTheme());
// Toggle theme
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'light';
setTheme(current === 'dark' ? 'light' : 'dark');
});
// Listen for system preference changes
prefersDark.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
</script>
| Theming Best Practice | Why | Implementation |
|---|---|---|
| Test Contrast in All Themes | Dark mode can fail contrast if not tested | Verify 4.5:1 ratio in light AND dark |
| Respect System Preferences | Users set preferences for a reason | prefers-color-scheme media query |
| Allow Manual Override | Some users want different than system | Theme toggle with localStorage |
| Smooth Transitions | Jarring changes can trigger issues | transition: background 0.3s, color 0.3s |
| Announce Theme Changes | Screen reader users need notification | aria-live announcement |
| Preserve Focus Visibility | Focus must be visible in all themes | Test focus indicators in each theme |
Note: CSS custom properties can't be used in media queries, but can be changed within them.
Define theme variations inside
@media blocks to adapt to user preferences.
Color and Visual Design Quick Reference
- Normal text needs 4.5:1 contrast (AA), 7:1 (AAA); large text needs 3:1 (AA), 4.5:1 (AAA)
- UI components and graphical objects need minimum 3:1 contrast against adjacent colors
- Never use color as the only visual means - add icons, text labels, patterns, or shapes
- Avoid red/green combinations - use blue/orange or add patterns/icons for colorblind users
- Support high contrast mode with system color keywords (ButtonFace, CanvasText, etc.)
- Links must be underlined or have 3:1 contrast with surrounding text
- Test designs with color blindness simulators and actual users
- Use CSS custom properties for themable, maintainable color systems
- Respect
prefers-color-schemeand provide manual theme toggle - Ensure focus indicators have 3:1 contrast in all themes (WCAG 2.2)