<!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>