Created
February 20, 2020 14:36
-
-
Save CMessinides/3e27a1012eba0c113a44b0075d8241e8 to your computer and use it in GitHub Desktop.
A simple color contrast checker
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* ============================================================================ | |
* Constants for later reference | |
* ============================================================================ | |
*/ | |
const WCAG_MINIMUM_RATIOS = [ | |
['AA Large', 3], | |
['AA', 4.5], | |
['AAA', 7], | |
] | |
/** | |
* ============================================================================ | |
* Setting up the app | |
* ============================================================================ | |
*/ | |
/* Get references to all the elements we'll need */ | |
let preview = document.getElementById('preview') | |
let statusText = document.getElementById('status-text') | |
let statusRatio = document.getElementById('status-ratio') | |
let statusLevel = document.getElementById('status-level') | |
let textColorInput = document.getElementById('input-text') | |
let bgColorInput = document.getElementById('input-background') | |
/* Attach the event listener */ | |
textColorInput.addEventListener('input', handleColorChange) | |
bgColorInput.addEventListener('input', handleColorChange) | |
/* Fire the listener once to initialize the app */ | |
handleColorChange() | |
/** | |
* ============================================================================ | |
* Event listener to update the app | |
* ============================================================================ | |
*/ | |
function handleColorChange() { | |
let textColor = textColorInput.value | |
let bgColor = bgColorInput.value | |
preview.style.color = textColor | |
preview.style.backgroundColor = bgColor | |
let ratio = checkContrast(textColor, bgColor) | |
let { didPass, maxLevel } = meetsMinimumRequirements(ratio) | |
statusText.classList.toggle('is-pass', didPass) | |
statusRatio.innerText = formatRatio(ratio) | |
statusLevel.innerText = didPass ? maxLevel : 'Fail' | |
} | |
/** | |
* ============================================================================ | |
* Utility functions for color luminance and contrast ratios | |
* ============================================================================ | |
*/ | |
/** | |
* Calculate the relative luminance of a color. See the defintion of relative | |
* luminance in the WCAG 2.0 guidelines: | |
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef | |
* | |
* @param {number} r The red component (0-255) | |
* @param {number} g The green component (0-255) | |
* @param {number} b The blue component (0-255) | |
* @returns {number} | |
*/ | |
function luminance(r, g, b) { | |
let [lumR, lumG, lumB] = [r, g, b].map(component => { | |
let proportion = component / 255; | |
return proportion <= 0.03928 | |
? proportion / 12.92 | |
: Math.pow((proportion + 0.055) / 1.055, 2.4); | |
}); | |
return 0.2126 * lumR + 0.7152 * lumG + 0.0722 * lumB; | |
} | |
/** | |
* Calculate the contrast ratio between the relative luminance values of two | |
* colors. See the definition of contrast ratio in the WCAG 2.0 guidelines: | |
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef | |
* | |
* @param {number} luminance1 The relative luminance of the first color (0-1) | |
* @param {number} luminance2 The relative luminance of the second color (0-1) | |
* @returns {number} | |
*/ | |
function contrastRatio(luminance1, luminance2) { | |
let lighterLum = Math.max(luminance1, luminance2); | |
let darkerLum = Math.min(luminance1, luminance2); | |
return (lighterLum + 0.05) / (darkerLum + 0.05); | |
} | |
/** | |
* Calculate the contrast ratio between two colors. The minimum contrast is 1, | |
* and the maximum is 21. | |
* | |
* @param {string} color1 The six-digit hex code of the first color | |
* @param {string} color2 The six-digit hex code of the second color | |
* @returns {number} | |
*/ | |
function checkContrast(color1, color2) { | |
let [luminance1, luminance2] = [color1, color2].map(color => { | |
/* Remove the leading hash sign if it exists */ | |
color = color.startsWith("#") ? color.slice(1) : color; | |
let r = parseInt(color.slice(0, 2), 16); | |
let g = parseInt(color.slice(2, 4), 16); | |
let b = parseInt(color.slice(4, 6), 16); | |
return luminance(r, g, b); | |
}); | |
return contrastRatio(luminance1, luminance2); | |
} | |
/** | |
* Format the given contrast ratio as a string (ex. "4.3:1" or "17:1") | |
* | |
* @param {number} ratio | |
* @returns {string} | |
*/ | |
function formatRatio(ratio) { | |
let ratioAsFloat = ratio.toFixed(2) | |
let isInteger = Number.isInteger(parseFloat(ratioAsFloat)) | |
return `${isInteger ? Math.floor(ratio) : ratioAsFloat}:1` | |
} | |
/** | |
* Determine whether the given contrast ratio meets WCAG requirements at any | |
* level (AA Large, AA, or AAA). In the return value, `isPass` is true if | |
* the ratio meets or exceeds the minimum of at least one level, and `maxLevel` | |
* is the strictest level that the ratio passes. | |
* | |
* @param {number} ratio The contrast ratio (1-21) | |
* @returns {{ isPass: boolean, maxLevel: "AAA"|"AA"|"AA Large" }} | |
*/ | |
function meetsMinimumRequirements(ratio) { | |
let didPass = false | |
let maxLevel = null | |
for (const [level, minRatio] of WCAG_MINIMUM_RATIOS) { | |
if (ratio < minRatio) break | |
didPass = true | |
maxLevel = level | |
} | |
return { didPass, maxLevel } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> | |
<!-- See Andy Bell, "A Modern CSS Reset": https://hankchizljaw.com/wrote/a-modern-css-reset/ --> | |
<link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" /> | |
<link rel="stylesheet" href="./styles.css"> | |
</head> | |
<body> | |
<main> | |
<div id='preview' class="preview"> | |
<div class="container"> | |
<h1 class="preview__text preview__text--large">Contrast Checker</h1> | |
<p class="preview__text preview__text--normal" aria-hidden="true"> | |
Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur | |
soluta doloremque quasi tenetur labore accusamus, ad rerum vel | |
nostrum eius dolorem repudiandae nam saepe. Id esse repellat | |
blanditiis temporibus? Tempora? | |
</p> | |
</div> | |
</div> | |
<div class="status"> | |
<p class="container"> | |
Contrast ratio: | |
<span id="status-text" class="status__text"> | |
<strong id="status-ratio" class="status__ratio"> | |
</strong> | |
<strong id="status-level" class="status__level"> | |
</strong> | |
</span> | |
</p> | |
</div> | |
<div class="controls"> | |
<div class="container"> | |
<div class="controls__group"> | |
<div class="control"> | |
<label class="control__label" for="input-text">Text Color</label> | |
<input class="control__input" type="color" id="input-text" value="#ffffff"> | |
</div> | |
<div class="control"> | |
<label class="control__label" for="input-background">Background Color</label> | |
<input class="control__input" type="color" id="input-background" value="#000000"> | |
</div> | |
</div> | |
</div> | |
</div> | |
</main> | |
<script src="./contrast.js"></script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
body { | |
font-size: 1rem; | |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; | |
line-height: 1.5; | |
} | |
.container { | |
max-width: 72rem; | |
margin-right: auto; | |
margin-left: auto; | |
padding-right: 24px; | |
padding-left: 24px; | |
} | |
@media (min-width: 36em) { | |
.container { | |
padding-right: 40px; | |
padding-left: 40px; | |
} | |
} | |
.preview { | |
padding: 4em 0 2em; | |
} | |
@media (min-width: 36em) { | |
.preview { | |
font-size: 1.5rem; | |
} | |
} | |
.preview__text--large { | |
font-size: 2.5em; | |
} | |
.preview__text--normal { | |
font-size: 1rem; | |
max-width: 40em; | |
} | |
.status { | |
color: #1e2b46; | |
padding: 1em 0; | |
background-color: #f9fafc; | |
border-top: 1px #d8deeb solid; | |
border-bottom: 1px #d8deeb solid; | |
} | |
.status__text { | |
color: #d51515; | |
} | |
.status__text.is-pass { | |
color: #057912; | |
} | |
.status__ratio, | |
.status__level { | |
display: inline-block; | |
padding-left: 12px; | |
} | |
.controls { | |
padding: 1rem 0; | |
} | |
.controls__group { | |
display: flex; | |
flex-wrap: wrap; | |
margin: -8px; | |
} | |
.control { | |
min-width: 12rem; | |
flex: 1 1 50%; | |
padding: 8px; | |
} | |
.control__label { | |
font-weight: 500; | |
display: block; | |
margin-bottom: 0.25rem; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment