1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
|
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Color Contrast Checker</title>
<meta name="description" content="Give it a list of colors, find accessible combinations.">
<style>
body { font-family: Arial, sans-serif; padding: 20px; size: 16px;}
.color-input { margin-bottom: 10px; width: 100%; height: 100px; font-size: 16px; /* Prevent iOS zoom */ }
.color-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.color-box { padding: 10px; text-align: center; border: 1px solid #ccc; }
.hidden { display: none; }
.form-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
margin-top: 1rem;
justify-content: space-between;
}
.checkbox-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
justify-content: flex-end;
}
@media (max-width: 600px) {
.form-controls {
flex-direction: column;
align-items: stretch;
}
.checkbox-controls {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
}
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
button[onclick="shareColors()"] {
background: #6c757d;
}
button[onclick="shareColors()"]:hover {
background: #545b62;
}
label {
white-space: nowrap;
}
.error {
color: #721c24;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
grid-column: 1 / -1;
}
.warning {
color: #856404;
background-color: #fff3cd;
border: 1px solid #ffeeba;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
grid-column: 1 / -1;
}
</style>
</head>
<body>
<header>
<h1>What color combinations are accessible?</h1>
</header>
<main>
<form id="colorForm" onsubmit="event.preventDefault(); updateColors();">
<label for="colors">Colors you want to check for contrast:</label>
<textarea id="colors" class="color-input" required placeholder="Feel free to mix and match hex, rgb, and rgba values!"></textarea>
<p style="margin-top: 0;">You can enter colors as a comma separated list, or as a newline separated list...or a mix of both! Colors can be in hex (#RGB or #RRGGBB), rgb(r,g,b), or rgba(r,g,b,a) format. You can mix and match color formats, too!</p>
<div class="form-controls">
<button type="submit">Check Contrast</button>
<div class="checkbox-controls">
<label><input type="checkbox" id="toggleFails" onchange="updateVisibility()"> Hide failing pairs</label>
<label><input type="checkbox" id="sortContrast" onchange="updateColors()"> Sort by contrast</label>
<button type="button" onclick="shareColors()">Share Palette</button>
</div>
</div>
</form>
<br>
<br>
<section id="results" class="color-grid" aria-live="polite"></section>
</main>
<footer>
<p>Color Contrast Checker — A tool to help you find accessible color combinations.</p>
<p>This tool can only determine if two color combinations have a mathmatically correct contrast ratio. Digital accessiblity isn't just about math, though, so use this as a starting place, not as the final word on if a color combination is accessible.</p>
</footer>
<script>
// ===== Initialization and URL Parameter Handling =====
window.addEventListener('load', () => {
const urlParams = new URLSearchParams(window.location.search);
const colors = urlParams.get('colors');
if (colors) {
document.getElementById('colors').value = decodeURIComponent(colors);
updateColors();
}
// Only create test UI if tests parameter is present and true
if (urlParams.get('tests') === 'true' || urlParams.get('tests') === 't') {
createTestUI();
}
});
// ===== Share Functionality =====
function shareColors() {
const colors = document.getElementById('colors').value;
if (!colors) return;
const encodedColors = encodeURIComponent(colors);
const url = `${window.location.origin}${window.location.pathname}?colors=${encodedColors}`;
// Copy to clipboard
navigator.clipboard.writeText(url).then(() => {
alert('Share link copied to clipboard!');
}).catch(() => {
// Fallback if clipboard API fails
prompt('Copy this share link:', url);
});
}
// ===== Color Parsing and Conversion =====
function parseColor(color) {
// Remove all whitespace and convert to lowercase
const originalColor = color; // Store original format
color = color.replace(/\s/g, '').toLowerCase();
// If it's just numbers, assume it's an error
if (/^\d+$/.test(color)) {
throw new Error(`Invalid color format: ${color}. Please use hex (#RGB or #RRGGBB) or rgb()/rgba() format.`);
}
// Add # prefix if it's a hex code without it
if (/^[0-9a-f]{3}$|^[0-9a-f]{6}$/.test(color)) {
color = '#' + color;
}
// Handle hex colors
if (color.startsWith('#')) {
// Validate hex format
if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(color)) {
throw new Error(`Invalid hex color format: ${color}. Use #RGB or #RRGGBB format.`);
}
// Convert 3-digit hex to 6-digit
if (color.length === 4) {
color = '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3];
}
// Remove # and convert to RGB array
const hex = color.substring(1);
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: 1,
originalFormat: originalColor
};
}
// Handle rgb/rgba colors
if (color.startsWith('rgb')) {
// Validate rgb/rgba format
if (!/^rgba?\(\d+(\.\d+)?(,\d+(\.\d+)?){2}(,\d*\.?\d+)?\)$/.test(color)) {
throw new Error(`Invalid rgb/rgba format: ${color}. Use rgb(r,g,b) or rgba(r,g,b,a) format.`);
}
const values = color.match(/[\d.]+/g);
const rgb = values.map(v => parseFloat(v));
// Validate rgb values are in range
if (rgb.slice(0, 3).some(v => v < 0 || v > 255)) {
throw new Error(`RGB values must be between 0 and 255: ${color}`);
}
if (rgb.length === 4 && (rgb[3] < 0 || rgb[3] > 1)) {
throw new Error(`Alpha value must be between 0 and 1: ${color}`);
}
return {
r: rgb[0],
g: rgb[1],
b: rgb[2],
a: rgb.length === 4 ? rgb[3] : 1,
originalFormat: originalColor
};
}
throw new Error(`Unsupported color format: ${color}. Please use hex (#RGB or #RRGGBB) or rgb()/rgba() format.`);
}
function colorToHex({r, g, b}) {
const toHex = (n) => {
const hex = Math.round(n).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// ===== Contrast Calculation and WCAG Compliance =====
function getContrastRatio(color1, color2) {
// Calculate relative luminance using WCAG formula
// L = 0.2126 * R + 0.7152 * G + 0.0722 * B
// where R, G, and B are the red, green, and blue values of the color
// the formula normalizes the RGB values to a 0-1 range
// and then applies coefficients to represent human perception of color
// Returns a value between 0 and 1, where 0 is darkest and 1 is brightest
function luminance(r, g, b) {
// Convert RGB values to sRGB colorspace values
let channels = [r, g, b].map(v => {
// Step 1: Normalize RGB values to 0-1 range
v /= 255;
// Step 2: Convert to sRGB using WCAG formula
// If value is small (≤ 0.03928), use simple linear calculation
// Otherwise, use the more complex power formula
return v <= 0.03928
? v / 12.92
: Math.pow((v + 0.055) / 1.055, 2.4);
});
// Step 3: Apply luminance coefficients
// These coefficients represent human perception of color
// Red contributes 21.26%, Green 71.52%, and Blue 7.22% to perceived brightness
return channels[0] * 0.2126 // Red coefficient
+ channels[1] * 0.7152 // Green coefficient
+ channels[2] * 0.0722; // Blue coefficient
}
// Handle colors with transparency by compositing them with white background
const parseAndComposite = (color) => {
const parsed = parseColor(color);
// If color is fully opaque, return as is
if (parsed.a === 1) return parsed;
// For semi-transparent colors, composite with white background
// Using alpha compositing formula: result = (foreground × alpha) + (background × (1 - alpha))
const alpha = parsed.a;
return {
r: (parsed.r * alpha) + (255 * (1 - alpha)), // Blend red channel
g: (parsed.g * alpha) + (255 * (1 - alpha)), // Blend green channel
b: (parsed.b * alpha) + (255 * (1 - alpha)), // Blend blue channel
a: 1 // Result is fully opaque
};
};
// Process both colors, handling any transparency
const rgb1 = parseAndComposite(color1);
const rgb2 = parseAndComposite(color2);
// Calculate luminance for both colors
const lum1 = luminance(rgb1.r, rgb1.g, rgb1.b);
const lum2 = luminance(rgb2.r, rgb2.g, rgb2.b);
// Calculate contrast ratio using WCAG formula:
// (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter color's luminance
// The 0.05 constant prevents division by zero and handles very dark colors
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
// This is an incomplete check since it is only dealing with color, and doesn't consider font sizing
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_WCAG/Perceivable/Color_contrast
function getWCAGLevel(contrast) {
// Level AAA: Contrast ratio of at least 7:1
if (contrast >= 7) return "AAA";
// Level AA: Contrast ratio of at least 4.5:1
if (contrast >= 4.5) return "AA";
// Failing: Contrast ratio below 4.5:1
return "Fail";
}
// ===== UI Generation and Updates =====
function createColorBox(color1, color2, contrast, level, accessible, showGlow) {
const classes = ["color-box"];
if (!accessible) classes.push("failing");
if (level === "AAA" && showGlow) classes.push("glowing-border");
const foreground = parseColor(color1);
const background = parseColor(color2);
return `
<div class="${classes.join(' ')}" style="background: ${background.originalFormat}; color: ${foreground.originalFormat};">
${foreground.originalFormat} on ${background.originalFormat}<br>
Ratio: ${contrast} ${accessible ? "✅" : "❌"}<br>
WCAG: ${level}
</div>
`;
}
function updateColors() {
const input = document.getElementById("colors").value;
const results = document.getElementById("results");
// First split by newlines
const colorLines = input.split(/\n+/).map(line => line.trim()).filter(line => line);
// Then process each line to handle both comma-separated values and rgb/rgba formats
let colors = colorLines.flatMap(line => {
// Temporary replace commas in rgb/rgba values with a special marker
line = line.replace(/rgba?\([^)]+\)/g, match => match.replace(/,/g, '|'));
// Split by commas and restore the original commas in rgb/rgba values
return line.split(',')
.map(color => color.trim().replace(/\|/g, ','))
.filter(color => color);
});
// Clear any existing warnings
document.querySelectorAll('.warning').forEach(el => el.remove());
// Deduplicate colors by converting to a standard format and back
colors = [...new Set(colors.map(color => {
try {
const parsed = parseColor(color);
return parsed.originalFormat;
} catch (error) {
return color; // Keep invalid colors for error handling
}
}))];
const duplicates = findDuplicateColors(colors);
if (duplicates.length > 0) {
console.warn('Duplicate colors found:', duplicates);
}
try {
const colorCombinations = colors.flatMap((color1, i) =>
colors.slice(i + 1).map(color2 => {
try {
const contrast = getContrastRatio(color1, color2).toFixed(2);
const level = getWCAGLevel(contrast);
const accessible = level !== "Fail";
return { color1, color2, contrast, level, accessible };
} catch (error) {
console.error(`Error processing colors ${color1} and ${color2}:`, error);
return null;
}
}).filter(combo => combo !== null)
);
const sortByContrast = document.getElementById("sortContrast").checked;
if (sortByContrast) {
colorCombinations.sort((a, b) => b.contrast - a.contrast);
}
const colorBoxes = colorCombinations.map(({ color1, color2, contrast, level, accessible }) => {
return createColorBox(color1, color2, contrast, level, accessible, false);
});
results.innerHTML = colorBoxes.join('');
updateVisibility();
} catch (error) {
results.innerHTML = `<div class="error">Error: ${error.message}</div>`;
}
}
function findDuplicateColors(colors) {
const seen = new Map();
const duplicates = new Set();
colors.forEach(color => {
try {
const parsed = parseColor(color);
const normalized = `rgb(${parsed.r},${parsed.g},${parsed.b},${parsed.a})`;
if (seen.has(normalized)) {
duplicates.add(color);
}
seen.set(normalized, color);
} catch (error) {
// Ignore invalid colors
}
});
return Array.from(duplicates);
}
function updateVisibility() {
let hideFails = document.getElementById("toggleFails").checked;
document.querySelectorAll(".failing").forEach(box => {
box.style.display = hideFails ? "none" : "block";
});
}
// ===== Testing Functions =====
function runContrastTests() {
const testCases = [
{
color1: '#000000',
color2: '#FFFFFF',
expectedRatio: 21, // Black on White should be ~21:1
description: 'Black on White - Maximum Contrast'
},
{
color1: '#000000',
color2: '#000000',
expectedRatio: 1, // Same colors should be 1:1
description: 'Same Colors - Minimum Contrast'
},
{
color1: '#000',
color2: 'rgb(255, 255, 255)',
expectedRatio: 21,
description: 'Short hex vs RGB - Should match black on white'
},
{
color1: 'rgba(0, 0, 0, 1)',
color2: '#FFFFFF',
expectedRatio: 21,
description: 'RGBA vs Hex - Should match black on white'
},
{
color1: 'rgba(0, 0, 0, 0.5)',
color2: '#FFFFFF',
expectedRatio: 3.95, // Corrected value based on WCAG formula
description: 'Semi-transparent black on white'
},
{
color1: '#777777',
color2: '#FFFFFF',
expectedRatio: 4.48, // Common gray on white
description: 'Gray on White - Just below AA'
},
{
color1: '#565656',
color2: '#FFFFFF',
expectedRatio: 7.34,
description: 'Dark Gray on White - AAA level'
},
{
color1: '#FF0000',
color2: '#FFFFFF',
expectedRatio: 3.99,
description: 'Red on White'
},
{
color1: 'rgba(0, 0, 0, 0.8)',
color2: '#FFFFFF',
expectedRatio: 12.63,
description: '80% transparent black on white'
},
{
color1: '#FFFFFF',
color2: '#FFFFFE',
expectedRatio: 1.0,
description: 'Nearly identical colors'
},
{
color1: 'rgba(0, 0, 0, 0.001)',
color2: '#FFFFFF',
expectedRatio: 1.0,
description: 'Nearly transparent color'
},
{
color1: '#808080',
color2: '#FFFFFF',
expectedRatio: 3.95,
description: '50% gray on white'
},
{
color1: '#FFFFFF',
color2: '#000000',
expectedRatio: 21,
description: 'White on Black - Should match Black on White'
}
];
function runTestGroup(tests) {
let groupResults = {
total: tests.length,
passed: 0,
failed: 0,
details: []
};
tests.forEach(test => {
const actualRatio = parseFloat(getContrastRatio(test.color1, test.color2).toFixed(2));
const expectedRatio = parseFloat(test.expectedRatio.toFixed(2));
const difference = Math.abs(actualRatio - expectedRatio);
const passed = difference <= 0.1;
groupResults.details.push({
description: test.description,
passed,
expected: expectedRatio,
actual: actualRatio,
difference
});
if (passed) groupResults.passed++;
else groupResults.failed++;
});
// Log results in a table format
console.table(groupResults.details);
return groupResults;
}
// Group tests by category
console.group('Running Contrast Ratio Tests');
console.group('Basic Contrast Tests');
const basicResults = runTestGroup(testCases.filter(t => !t.color1.includes('rgba')));
console.groupEnd();
console.group('Transparency Tests');
const transparencyResults = runTestGroup(testCases.filter(t => t.color1.includes('rgba')));
console.groupEnd();
console.groupEnd();
// Return combined results
return {
total: basicResults.total + transparencyResults.total,
passed: basicResults.passed + transparencyResults.passed,
failed: basicResults.failed + transparencyResults.failed
};
}
function runValidationTests() {
const invalidCases = [
{
input: 'not-a-color',
expectedError: 'Unsupported color format'
},
{
input: '#GGG',
expectedError: 'Invalid hex color format'
},
{
input: 'rgb(256,0,0)',
expectedError: 'RGB values must be between 0 and 255'
},
{
input: 'rgba(0,0,0,1.1)',
expectedError: 'Alpha value must be between 0 and 1'
}
];
console.group('Running Validation Tests');
let results = {
total: invalidCases.length,
passed: 0,
failed: 0
};
invalidCases.forEach(test => {
try {
parseColor(test.input);
console.error(`❌ FAILED: Expected error for "${test.input}"`);
results.failed++;
} catch (error) {
if (error.message.includes(test.expectedError)) {
console.log(`✅ PASSED: Correctly rejected "${test.input}"`);
results.passed++;
} else {
console.error(`❌ FAILED: Wrong error for "${test.input}"`);
console.error(` Expected: ${test.expectedError}`);
console.error(` Got: ${error.message}`);
results.failed++;
}
}
});
console.groupEnd();
return results;
}
function createTestUI() {
const testSection = document.createElement('section');
testSection.innerHTML = `
<div class="test-controls">
<h2>Test Suite</h2>
<button onclick="runContrastTests()">Run Contrast Tests</button>
<button onclick="runValidationTests()">Run Validation Tests</button>
<button onclick="runAllTests()">Run All Tests</button>
<div id="test-results"></div>
</div>
`;
// Add some styles
const style = document.createElement('style');
style.textContent = `
.test-controls {
margin: 20px 0;
padding: 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
.test-controls button {
margin: 0 10px 10px 0;
}
#test-results {
margin-top: 20px;
font-family: monospace;
}
`;
document.head.appendChild(style);
document.querySelector('main').appendChild(testSection);
}
function runAllTests() {
const contrastResults = runContrastTests();
const validationResults = runValidationTests();
const resultsDiv = document.getElementById('test-results');
resultsDiv.innerHTML = `
<h3>Test Results</h3>
<p>Contrast Tests: ${contrastResults.passed}/${contrastResults.total} passed</p>
<p>Validation Tests: ${validationResults.passed}/${validationResults.total} passed</p>
`;
}
</script>
</body>
</html>
|