Nailing the Perfect Contrast Between Light Text and a Background Image

Avatar of Yaphi Berhanu
Yaphi Berhanu on (Updated on )

Have you ever come across a site where light text is sitting on a light background image? If you have, you’ll know how difficult that is to read. A popular way to avoid that is to use a transparent overlay. But this leads to an important question: Just how transparent should that overlay be? It’s not like we’re always dealing with the same font sizes, weights, and colors, and, of course, different images will result in different contrasts.

Trying to stamp out poor text contrast on background images is a lot like playing Whac-a-Mole. Instead of guessing, we can solve this problem with HTML <canvas> and a little bit of math.

Like this:

We could say “Problem solved!” and simply end this article here. But where’s the fun in that? What I want to show you is how this tool works so you have a new way to handle this all-too-common problem.

Here’s the plan

First, let’s get specific about our goals. We’ve said we want readable text on top of a background image, but what does “readable” even mean? For our purposes, we’ll use the WCAG definition of AA-level readability, which says text and background colors need enough contrast between them such that that one color is 4.5 times lighter than the other.

Let’s pick a text color, a background image, and an overlay color as a starting point. Given those inputs, we want to find the overlay opacity level that makes the text readable without hiding the image so much that it, too, is difficult to see. To complicate things a bit, we’ll use an image with both dark and light space and make sure the overlay takes that into account.

Our final result will be a value we can apply to the CSS opacity property of the overlay that gives us the right amount of transparency that makes the text 4.5 times lighter than the background.

Optimal overlay opacity: 0.521

To find the optimal overlay opacity we’ll go through four steps:

  1. We’ll put the image in an HTML <canvas>, which will let us read the colors of each pixel in the image.
  2. We’ll find the pixel in the image that has the least contrast with the text.
  3. Next, we’ll prepare a color-mixing formula we can use to test different opacity levels on top of that pixel’s color.
  4. Finally, we’ll adjust the opacity of our overlay until the text contrast hits the readability goal. And these won’t just be random guesses — we’ll use binary search techniques to make this process quick.

Let’s get started!

Step 1: Read image colors from the canvas

Canvas lets us “read” the colors contained in an image. To do that, we need to “draw” the image onto a <canvas> element and then use the canvas context (ctx) getImageData() method to produce a list of the image’s colors.

function getImagePixelColorsUsingCanvas(image, canvas) {
  // The canvas's context (often abbreviated as ctx) is an object
  // that contains a bunch of functions to control your canvas
  const ctx = canvas.getContext('2d');


  // The width can be anything, so I picked 500 because it's large
  // enough to catch details but small enough to keep the
  // calculations quick.
  canvas.width = 500;


  // Make sure the canvas matches proportions of our image
  canvas.height = (image.height / image.width) * canvas.width;


  // Grab the image and canvas measurements so we can use them in the next step
  const sourceImageCoordinates = [0, 0, image.width, image.height];
  const destinationCanvasCoordinates = [0, 0, canvas.width, canvas.height];


  // Canvas's drawImage() works by mapping our image's measurements onto
  // the canvas where we want to draw it
  ctx.drawImage(
    image,
    ...sourceImageCoordinates,
    ...destinationCanvasCoordinates
  );


  // Remember that getImageData only works for same-origin or 
  // cross-origin-enabled images.
  // https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
  const imagePixelColors = ctx.getImageData(...destinationCanvasCoordinates);
  return imagePixelColors;
}

The getImageData() method gives us a list of numbers representing the colors in each pixel. Each pixel is represented by four numbers: red, green, blue, and opacity (also called “alpha”). Knowing this, we can loop through the list of pixels and find whatever info we need. This will be useful in the next step.

Image of a blue and purple rose on a light pink background. A section of the rose is magnified to reveal the RGBA values of a specific pixel.

Step 2: Find the pixel with the least contrast

Before we do this, we need to know how to calculate contrast. We’ll write a function called getContrast() that takes in two colors and spits out a number representing the level of contrast between the two. The higher the number, the better the contrast for legibility.

When I started researching colors for this project, I was expecting to find a simple formula. It turned out there were multiple steps.

To calculate the contrast between two colors, we need to know their luminance levels, which is essentially the brightness (Stacie Arellano does a deep dive on luminance that’s worth checking out.)

Thanks to the W3C, we know the formula for calculating contrast using luminance:

const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);

Getting the luminance of a color means we have to convert the color from the regular 8-bit RGB value used on the web (where each color is 0-255) to what’s called linear RGB. The reason we need to do this is that brightness doesn’t increase evenly as colors change. We need to convert our colors into a format where the brightness does vary evenly with color changes. That allows us to properly calculate luminance. Again, the W3C is a help here:

const luminance = (0.2126 * getLinearRGB(r) + 0.7152 * getLinearRGB(g) + 0.0722 * getLinearRGB(b));

But wait, there’s more! In order to convert 8-bit RGB (0 to 255) to linear RGB, we need to go through what’s called standard RGB (also called sRGB), which is on a scale from 0 to 1.

So the process goes: 

8-bit RGB → standard RGB  → linear RGB → luminance

And once we have the luminance of both colors we want to compare, we can plug in the luminance values to get the contrast between their respective colors.

// getContrast is the only function we need to interact with directly.
// The rest of the functions are intermediate helper steps.
function getContrast(color1, color2) {
  const color1_luminance = getLuminance(color1);
  const color2_luminance = getLuminance(color2);
  const lighterColorLuminance = Math.max(color1_luminance, color2_luminance);
  const darkerColorLuminance = Math.min(color1_luminance, color2_luminance);
  const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
  return contrast;
}


function getLuminance({r,g,b}) {
  return (0.2126 * getLinearRGB(r) + 0.7152 * getLinearRGB(g) + 0.0722 * getLinearRGB(b));
}
function getLinearRGB(primaryColor_8bit) {
  // First convert from 8-bit rbg (0-255) to standard RGB (0-1)
  const primaryColor_sRGB = convert_8bit_RGB_to_standard_RGB(primaryColor_8bit);


  // Then convert from sRGB to linear RGB so we can use it to calculate luminance
  const primaryColor_RGB_linear = convert_standard_RGB_to_linear_RGB(primaryColor_sRGB);
  return primaryColor_RGB_linear;
}
function convert_8bit_RGB_to_standard_RGB(primaryColor_8bit) {
  return primaryColor_8bit / 255;
}
function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB) {
  const primaryColor_linear = primaryColor_sRGB < 0.03928 ?
    primaryColor_sRGB/12.92 :
    Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);
  return primaryColor_linear;
}

Now that we can calculate contrast, we’ll need to look at our image from the previous step and loop through each pixel, comparing the contrast between that pixel’s color and the foreground text color. As we loop through the image’s pixels, we’ll keep track of the worst (lowest) contrast so far, and when we reach the end of the loop, we’ll know the worst-contrast color in the image.

function getWorstContrastColorInImage(textColor, imagePixelColors) {
  let worstContrastColorInImage;
  let worstContrast = Infinity; // This guarantees we won't start too low
  for (let i = 0; i < imagePixelColors.data.length; i += 4) {
    let pixelColor = {
      r: imagePixelColors.data[i],
      g: imagePixelColors.data[i + 1],
      b: imagePixelColors.data[i + 2],
    };
    let contrast = getContrast(textColor, pixelColor);
    if(contrast < worstContrast) {
      worstContrast = contrast;
      worstContrastColorInImage = pixelColor;
    }
  }
  return worstContrastColorInImage;
}

Step 3: Prepare a color-mixing formula to test overlay opacity levels

Now that we know the worst-contrast color in our image, the next step is to establish how transparent the overlay should be and see how that changes the contrast with the text.

When I first implemented this, I used a separate canvas to mix colors and read the results. However, thanks to Ana Tudor’s article about transparency, I now know there’s a convenient formula to calculate the resulting color from mixing a base color with a transparent overlay.

For each color channel (red, green, and blue), we’d apply this formula to get the mixed color:

mixedColor = baseColor + (overlayColor - baseColor) * overlayOpacity

So, in code, that would look like this:

function mixColors(baseColor, overlayColor, overlayOpacity) {
  const mixedColor = {
    r: baseColor.r + (overlayColor.r - baseColor.r) * overlayOpacity,
    g: baseColor.g + (overlayColor.g - baseColor.g) * overlayOpacity,
    b: baseColor.b + (overlayColor.b - baseColor.b) * overlayOpacity,
  }
  return mixedColor;
}

Now that we’re able to mix colors, we can test the contrast when the overlay opacity value is applied.

function getTextContrastWithImagePlusOverlay({textColor, overlayColor, imagePixelColor, overlayOpacity}) {
  const colorOfImagePixelPlusOverlay = mixColors(imagePixelColor, overlayColor, overlayOpacity);
  const contrast = getContrast(textColor, colorOfImagePixelPlusOverlay);
  return contrast;
}

With that, we have all the tools we need to find the optimal overlay opacity!

Step 4: Find the overlay opacity that hits our contrast goal

We can test an overlay’s opacity and see how that affects the contrast between the text and image. We’re going to try a bunch of different opacity levels until we find the contrast that hits our mark where the text is 4.5 times lighter than the background. That may sound crazy, but don’t worry; we’re not going to guess randomly. We’ll use a binary search, which is a process that lets us quickly narrow down the possible set of answers until we get a precise result.

Here’s how a binary search works:

  • Guess in the middle.
  • If the guess is too high, we eliminate the top half of the answers. Too low? We eliminate the bottom half instead.
  • Guess in the middle of that new range.
  • Repeat this process until we get a value.

I just so happen to have a tool to show how this works:

In this case, we’re trying to guess an opacity value that’s between 0 and 1. So, we’ll guess in the middle, test whether the resulting contrast is too high or too low, eliminate half the options, and guess again. If we limit the binary search to eight guesses, we’ll get a precise answer in a snap.

Before we start searching, we’ll need a way to check if an overlay is even necessary in the first place. There’s no point optimizing an overlay we don’t even need!

function isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast) {
  const contrastWithoutOverlay = getContrast(textColor, worstContrastColorInImage);
  return contrastWithoutOverlay < desiredContrast;
}

Now we can use our binary search to look for the optimal overlay opacity:

function findOptimalOverlayOpacity(textColor, overlayColor, worstContrastColorInImage, desiredContrast) {
  // If the contrast is already fine, we don't need the overlay,
  // so we can skip the rest.
  const isOverlayNecessary = isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast);
  if (!isOverlayNecessary) {
    return 0;
  }


  const opacityGuessRange = {
    lowerBound: 0,
    midpoint: 0.5,
    upperBound: 1,
  };
  let numberOfGuesses = 0;
  const maxGuesses = 8;


  // If there's no solution, the opacity guesses will approach 1,
  // so we can hold onto this as an upper limit to check for the no-solution case.
  const opacityLimit = 0.99;


  // This loop repeatedly narrows down our guesses until we get a result
  while (numberOfGuesses < maxGuesses) {
    numberOfGuesses++;


    const currentGuess = opacityGuessRange.midpoint;
    const contrastOfGuess = getTextContrastWithImagePlusOverlay({
      textColor,
      overlayColor,
      imagePixelColor: worstContrastColorInImage,
      overlayOpacity: currentGuess,
    });


    const isGuessTooLow = contrastOfGuess < desiredContrast;
    const isGuessTooHigh = contrastOfGuess > desiredContrast;
    if (isGuessTooLow) {
      opacityGuessRange.lowerBound = currentGuess;
    }
    else if (isGuessTooHigh) {
      opacityGuessRange.upperBound = currentGuess;
    }


    const newMidpoint = ((opacityGuessRange.upperBound - opacityGuessRange.lowerBound) / 2) + opacityGuessRange.lowerBound;
    opacityGuessRange.midpoint = newMidpoint;
  }


  const optimalOpacity = opacityGuessRange.midpoint;
  const hasNoSolution = optimalOpacity > opacityLimit;


  if (hasNoSolution) {
    console.log('No solution'); // Handle the no-solution case however you'd like
    return opacityLimit;
  }
  return optimalOpacity;
}

With our experiment complete, we now know exactly how transparent our overlay needs to be to keep our text readable without hiding the background image too much.

We did it!

Improvements and limitations

The methods we’ve covered only work if the text color and the overlay color have enough contrast to begin with. For example, if you were to choose a text color that’s the same as your overlay, there won’t be an optimal solution unless the image doesn’t need an overlay at all.

In addition, even if the contrast is mathematically acceptable, that doesn’t always guarantee it’ll look great. This is especially true for dark text with a light overlay and a busy background image. Various parts of the image may distract from the text, making it difficult to read even when the contrast is numerically fine. That’s why the popular recommendation is to use light text on a dark background.

We also haven’t taken where the pixels are located into account or how many there are of each color. One drawback of that is that a pixel in the corner could possibly exert too much influence on the result. The benefit, however, is that we don’t have to worry about how the image’s colors are distributed or where the text is because, as long as we’ve handled where the least amount of contrast is, we’re safe everywhere else.

I learned a few things along the way

There are some things I walked away with after this experiment, and I’d like to share them with you:

  • Getting specific about a goal really helps! We started with a vague goal of wanting readable text on an image, and we ended up with a specific contrast level we could strive for.
  • It’s so important to be clear about the terms. For example, standard RGB wasn’t what I expected. I learned that what I thought of as “regular” RGB (0 to 255) is formally called 8-bit RGB. Also, I thought the “L” in the equations I researched meant “lightness,” but it actually means “luminance,” which is not to be confused with “luminosity.” Clearing up terms helps how we code as well as how we discuss the end result.
  • Complex doesn’t mean unsolvable. Problems that sound hard can be broken into smaller, more manageable pieces.
  • When you walk the path, you spot the shortcuts. For the common case of white text on a black transparent overlay, you’ll never need an opacity over 0.54 to achieve WCAG AA-level readability.

In summary…

You now have a way to make your text readable on a background image without sacrificing too much of the image. If you’ve gotten this far, I hope I’ve been able to give you a general idea of how it all works.

I originally started this project because I saw (and made) too many website banners where the text was tough to read against a background image or the background image was overly obscured by the overlay. I wanted to do something about it, and I wanted to give others a way to do the same. I wrote this article in hopes that you’d come away with a better understanding of readability on the web. I hope you’ve learned some neat canvas tricks too.

If you’ve done something interesting with readability or canvas, I’d love to hear about it in the comments!