Working with contrast ratio and accessibility guidelines

4 min read

I was working recently on a project, where I had to implement color theme generator. User could upload an image, and the application would generate a color palette based on it. Colors were then used to style text, background, and other elements. When testing, I noticed that some of the themes resulted in poorly readable text. To improve my implementation, I decided to revisit the topic of contrast ratio and accessibility guidelines.

What is contrast ratio and why it is important?

Contrast ratio is a measure of the difference between luminance of two colors. It is important because it affects readability of text and other elements on the page.

For example, contrast ratio of black text on a white background is 21:1. If you put a black text on a black background, the contrast ratio will be 1:1. The lower the contrast ratio, the harder it is to read the text.

Contrast ratio guidelines

WCAG is a set of guidelines published by the World Wide Web Consortium (W3C). Guidelines are organized under four principles:

  • Perceivable - information and user interface components must be presentable to users in ways they can perceive.
  • Operable - user interface components and navigation must be operable.
  • Understandable - information and the operation of user interface must be understandable.
  • Robust - content must be robust enough that it can be interpreted reliably by a wide variety of user agents, including assistive technologies.

Contrast ratio falls under the Perceivable principle and is calculated using a formula specified in the guidelines. We could test it against two levels of conformance: Minimum (AA) and Enhanced (AAA).

Working with contrast ratio in code

We need to calculate relative luminance first. To learn more about it, we could refer to Transformation section of this Wikipedia article about sRGB standard.

sRGB is a standard color space for the web. It was originally proposed by Microsoft and HP in 1996 and was adopted by the W3C.

Calculating luminance

First, we need to convert sRGB color values to linear values. We can do that by running the following function for each color channel (red, green, blue).

function sRgbToLinear(color: number) {
  const decimalColor = color / 255;
  
  return decimalColor <= 0.04045
    ? decimalColor / 12.92
    : ((decimalColor + 0.055) / 1.055) ** 2.4;
}

Next, we could create a function that takes a [R, G, B] tuple as an argument and returns luminance of a color. First we convert all color channels to linear values, then we apply the formula using luminance coefficients for each channel.

function calculateLuminance(color: number[]) {
  const [red, green, blue] = color.map(sRgbToLinear);

  return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}

Calculating contrast ratio

Now that we know how to get luminance value for colors, we can calculate contrast ratio. Let’s adopt the formula from the guidelines.

function calculateContrastRatio(lighterColorLuminance: number, darkerColorLuminance: number) {
  return (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
}

Notice that we have to pass arguments in specific order, lighter first, darker second. Also, we have to pass luminance, not colors, which is not very convenient. We could rewrite calculateContrastRatio to it’s next form, one that takes colors as arguments, in any order.

Let’s also introduce a simple converter function, that would enable calculateContrastRatio to accept colors as hex values.

function hexToRgb(color: `#${string}`) {
    return color
        .substring(1)
        .match(/.{2}/g)
        ?.map((component) => parseInt(component, 16));
}

We could now rewrite calculateContrastRatio function to its final form.

function calculateContrastRatio(firstColor: `#${string}`, secondColor: `#${string}`) {
  const firstColorLuminance = calculateLuminance(hexToRgb(firstColor));
  const secondColorLuminance = calculateLuminance(hexToRgb(secondColor));
  
  const lighterColorLuminance = Math.max(firstColorLuminance, secondColorLuminance);
  const darkerColorLuminance = Math.min(firstColorLuminance, secondColorLuminance);

  return (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
}

Testing conformance with accessibility guidelines

In the final step, let’s use our new and shiny calculateContrastRatio function to create enhanced conformance checker for text. According to the guidelines, it requires a contrast ratio of at least 7:1.

function isTextAAAConformant(textColor: `#${string}`, backgroundColor: `#${string}`) {
  return calculateContrastRatio(textColor, backgroundColor) >= 7;
}

Extended versions of such checkers could be used as input validator for a color picker or to programmatically adjust lightness of a color to meet the accessibility requirements.

Conclusion

Web should be all about accessibility, usability, and inclusion. When working with colors, we should always keep in mind that some of our users might have problems consuming our content. Use guidelines and tools to test your products and make sure that they work for everyone.