Skip to content

Instantly share code, notes, and snippets.

@CMessinides
Last active July 9, 2023 16:26
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save CMessinides/2daa7fafdab22d1ed1afad131225dccf to your computer and use it in GitHub Desktop.
Save CMessinides/2daa7fafdab22d1ed1afad131225dccf to your computer and use it in GitHub Desktop.
Color Contrast Checker
/**
* ============================================================================
* 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 }
}
<!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>
<!-- Preview -->
<div id='preview' class="preview">
<div class="container">
<!-- Some large text -->
<h1 class="preview__text preview__text--large">Contrast Checker</h1>
<!-- Some body text, which we hide from screenreaders, since it's just placeholder content -->
<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>
<!-- Status bar -->
<div class="status">
<p class="container">
Contrast ratio:
<span id="status-text" class="status__text">
<!-- We'll inject the contrast ratio into this element with JS -->
<strong id="status-ratio" class="status__ratio"></strong>
<!-- We'll inject the level that the ratio meets (AAA, AA, AA Large, or Fail) into this element with JS -->
<strong id="status-level" class="status__level"></strong>
</span>
</p>
</div>
<!-- Controls -->
<div class="controls">
<div class="container">
<div class="controls__group">
<!-- Text color input -->
<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>
<!-- Background color input -->
<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>
/* Set up global text styles on the body */
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;
}
/* A generic container component */
.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 styles */
.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 bar styles */
.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;
}
/* Control styles */
.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;
}
@Martinhessmann
Copy link

Awesome, that you are sharing your findings, I find it fascinating to test it.

I have found a weird bug though: when I try to calculate the contrast between FFFFFF and FF0200, it shows me 3.99, which is correct, but if I enter FF0000 instead, it goes to 3.0 where instead it should result in 4.0

@CMessinides
Copy link
Author

Thanks for reporting that bug! I haven't tested anything yet, but if I had to bet, it's probably a bug in the formatRatio function -- there's some hacky float formatting in there that could probably be done better. I'll have a chance to look into this more this afternoon.

@Martinhessmann
Copy link

Yeah you're very welcome! I found it fascinating, that you'd share your thoughts in the medium article and let others try out the code. I put it on https://codepen.io/snoobdog/pen/oNQobPr just to play with it. I can take it down if you mind or attribute you better, if I made a mistake there.

But about the formula: I tested a couple of color combinations and 99% of the resulting number ratios are the same as any other WCAG testing tool out there. Just for that one, which is pure red, it seems to be off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment