Skip to content

Blog

Using color analysis to improve accessibility

Text on top of images is one of those design patterns that everyone loves. Right up until an editor uploads a photo with a bright, overexposed sky exactly where the headline sits, and your crisp white headline turns into a ghost.

The usual fix is to slap a dark gradient on top of every image and call it a day. It works, sort of, but it also makes every image a bit gloomier, including the ones that never needed it. And it doesn't help one bit if the design calls for dark text.

A better approach is to look at each image and adapt to it. That's a lot of work if you're a human, but not if you're Imager X. Imager has had color analysis built in for ages, and together with a handful of color helper functions, it can make these decisions for you, on the fly.

Reading the dominant color

The main tool for the job is craft.imagerx.getDominantColor(). Hand it an image, and you get back the most common color in it, as a hex value by default, or as an RGB array if you ask for it:

{% set color = craft.imagerx.getDominantColor(image) %}
{# '#2d4a6b' #}

{% set rgb = craft.imagerx.getDominantColor(image, 10, 'rgb') %}
{# [45, 74, 107] #}

The full signature is getDominantColor(image, quality, colorValue, area). We'll get back to quality and area in a bit, because that's where the interesting stuff happens.

There's also getColorPalette(image, colorCount, quality, colorValue, area), which gives you a list of the most prominent colors instead of just the one. We'll use that too.

A quick note if you're on an older version of Imager: the dominant color got a lot smarter in Imager X 6.2.0. Before that, it was the first color of a palette made by Color Thief, which isn't ranked by pixel count alone, and which skips white pixels altogether. A white product on a white background would get the product's color as its dominant color, and a pure white image would get nothing at all. Not ideal when you're about to decide whether white text is a good idea! Now it's simply the most common color, white included. So if you're going to use this for accessibility, make sure you're on 6.2.0 or later.

Making decisions

A hex value on its own doesn't tell us much, so Imager also comes with a bunch of color utility functions, like getBrightness(), looksBright() and getRelativeLuminance(). The most useful one for accessibility is getContrastRatio(), which calculates the contrast ratio between two colors the same way WCAG does. That's the number that needs to be at least 4.5:1 for normal text, or 3:1 for large text, to pass level AA.

So let's ask it whether white or black text works best on top of our image:

{% set bg = craft.imagerx.getDominantColor(image) ?: '#ffffff' %}

{% set whiteContrast = craft.imagerx.getContrastRatio(bg, '#ffffff') %}
{% set blackContrast = craft.imagerx.getContrastRatio(bg, '#000000') %}
{% set lightText = whiteContrast > blackContrast %}

<section class="hero {{ lightText ? 'hero--light-text' : 'hero--dark-text' }}">
    ...
</section>

Notice the ?: '#ffffff' at the end of the first line. getDominantColor() returns null if the image can't be analyzed, for instance if the file is missing, or if every pixel in it is transparent. A transparent image shows whatever is behind it, which on most sites is a light background, so white is a reasonable fallback. Use whatever makes sense for your design.

Also note that we're setting a modifier class rather than just a text color. It's rarely only the headline that sits on top of the image. It's buttons, icons, and maybe even the site logo and navigation. With a class, all of them can switch together in your CSS.

And here's a fun fact: if you pick whichever of black and white has the most contrast, you're guaranteed a contrast ratio of at least 4.58:1 against any solid color. So against the dominant color, we'll always pass AA. The catch, of course, is that photos aren't solid colors.

Overlays that adapt

The dominant color is an approximation, and a headline across a sunset can have great contrast on one end and none on the other. So we still want an overlay. We just don't want it any stronger than it needs to be.

Since we know how much contrast our text color has against the dominant color, we can use that to decide how strong the overlay should be. A ratio close to the 4.5:1 limit means that parts of the image behind the text are probably below it, so we add a stronger scrim. If the ratio is way up there, a hint will do:

{% set contrast = max(whiteContrast, blackContrast) %}

{# Closer to the 4.5:1 limit means a stronger scrim #}
{% set scrimOpacity = contrast >= 10 ? 0.1 : (contrast >= 7 ? 0.25 : 0.5) %}
{% set scrimColor = lightText ? '0, 0, 0' : '255, 255, 255' %}

<div class="hero__scrim"
     style="background: linear-gradient(to top, rgba({{ scrimColor }}, {{ scrimOpacity }}), transparent 60%)"></div>

Or, and this is my favorite trick, use the dominant color itself for the overlay:

<div class="hero__scrim"
     style="background: linear-gradient(to top, {{ bg }}, transparent 60%)"></div>

This is sneaky in a good way. We chose the text color based on its contrast with bg, and now we're fading bg in right behind the text. The more opaque the gradient is behind the text, the closer the background gets to the solid color we measured, which we know passes. And it looks a lot better than a generic black gradient, because the overlay actually belongs to the image.

The same goes for other colors on the page. Set the dominant color as the background of the image container, and the space where the image is loading will already have the right color. Or use getColorPalette() to pull an accent color from the image for buttons and links, while making sure it has enough contrast against the background (3:1 is the minimum for UI components):

{% set palette = craft.imagerx.getColorPalette(image, 6) %}
{% set accent = palette|filter(color => craft.imagerx.getContrastRatio(color, bg) >= 3)|first %}

<a class="button" href="{{ entry.url }}"{% if accent %} style="--button-color: {{ accent }}"{% endif %}>Read more</a>

Sample small

Now for the important part. Color analysis isn't free. To find the dominant color, Imager needs a local copy of the image, has to decode it, and then loop over its pixels. If you pass in the original asset, that can mean downloading a 6000 x 4000 pixel photo from S3 and decoding all 24 million pixels of it into memory. In my tests, finding the dominant color of a photo that size took over three seconds. Per image. Do that on a listing page with 24 cards, and someone's going to have a bad time.

The fix is simple. Don't analyze the original, analyze a small transform of it:

{% set sample = craft.imagerx.transformImage(image, { width: 160, ratio: 16/9 }) %}
{% set bg = craft.imagerx.getDominantColor(sample.url, 1) ?: '#ffffff' %}

A 160 x 90 pixel image is 14,400 pixels, which is next to nothing. So little, in fact, that we can set quality to 1. The quality parameter decides how many pixels are skipped between each one that's sampled, and the default of 10 means only every tenth pixel is looked at. On an image this small, we can easily afford to look at all of them. Even then, it takes around 20 milliseconds, while the 6000 x 4000 original took over three seconds looking at only every tenth pixel. And since we're after the overall color, not the details, you're not losing anything that matters by scaling the image down first.

A few more things worth knowing:

  • getDominantColor() takes an asset or a path/URL, so pass in the transform's url.
  • The result is stored in Craft's data cache, so the analysis isn't repeated on every request. But it will be repeated when the cache expires or is cleared, and on a page with lots of images, it adds up.
  • If you're using a cloud transformer, like imgix or Cloudflare Images, Imager downloads the transformed image to analyze it. Yet another reason to keep it small.
  • If you already have a small transform of the image on the page in the same ratio, like a thumbnail, reuse it. It's already been created, so the only cost is the analysis.

Sample where the text is

The dominant color of the whole image is a decent guess, but it's still a guess. Picture a hero image with a dark forest at the bottom and a bright sky at the top. If the forest takes up most of the image, the dominant color is going to be a dark green, which tells us that white text is a great idea. But if the headline sits in the top left corner, on top of that bright sky, white text is the worst possible choice.

What we really want to know is the color of the part of the image that's behind the text. There are two ways to get there.

Option 1: Tell Imager where to look

The last parameter of getDominantColor(), area, lets you specify a rectangle to analyze. It takes x, y, w and h, in pixels, relative to the image you pass in. Since we're analyzing our small sample, we calculate it from the sample's dimensions. In this case, the headline sits in the lower left of the hero, so we sample the left half of the bottom 40%:

{% set sample = craft.imagerx.transformImage(image, { width: 160, ratio: 16/9 }) %}

{% set textArea = {
    x: 0,
    y: sample.height * 3 // 5,
    w: sample.width // 2,
    h: sample.height - (sample.height * 3 // 5)
} %}

{% set bg = craft.imagerx.getDominantColor(sample.url, 1, 'hex', textArea) ?: '#ffffff' %}

The values are in whole pixels, hence the // operator (division rounded down to an integer), and the area has to stay inside the image, or Imager will throw an exception.

Also notice that the sample has the same 16:9 ratio as the hero is displayed in. That way it's cropped the same way as the image your visitors actually see, focal point and all, and the area we analyze is the area that's actually behind the text.

Option 2: Crop it with a transform

If the text sits in a strip along one of the edges, you can let the transform do the cropping instead. By combining ratio and position, you can cut out just that part of the image, and analyze the whole thing:

{# The top third of a 16:9 image, where our headline sits #}
{% set textStrip = craft.imagerx.transformImage(image, {
    width: 160,
    ratio: 16/3,
    position: 'top-center'
}) %}

{% set bg = craft.imagerx.getDominantColor(textStrip.url, 1) ?: '#ffffff' %}

A 16:3 crop is exactly the top third of a 16:9 image. It's a bit less precise though, since the crop is made from the original, not from the 16:9 version that's displayed. If the original has a different aspect ratio, the strip won't line up exactly with what's behind the text. But it's simple and readable, and often close enough.

Putting it all together

Here's what a complete hero could look like, using the Power Pack for the image itself:

_components/hero.twig
{% set image = entry.heroImage.one() %}

{# A small sample, in the same ratio as the hero is displayed #}
{% set sample = craft.imagerx.transformImage(image, { width: 160, ratio: 16/9 }) %}

{# The headline sits in the lower left #}
{% set textArea = {
    x: 0,
    y: sample.height * 3 // 5,
    w: sample.width // 2,
    h: sample.height - (sample.height * 3 // 5)
} %}

{% set bg = craft.imagerx.getDominantColor(sample.url, 1, 'hex', textArea) ?: '#ffffff' %}

{% set whiteContrast = craft.imagerx.getContrastRatio(bg, '#ffffff') %}
{% set blackContrast = craft.imagerx.getContrastRatio(bg, '#000000') %}
{% set lightText = whiteContrast > blackContrast %}

<section class="hero {{ lightText ? 'hero--light-text' : 'hero--dark-text' }}" style="background-color: {{ bg }}">
    {{ ppimg(image, [800, 2000, 16/9], {
        class: 'hero__image',
        loading: 'eager',
        fetchpriority: 'high'
    }) }}

    <div class="hero__scrim" style="background: linear-gradient(to top, {{ bg }}, transparent 60%)"></div>

    <h1 class="hero__title">{{ entry.title }}</h1>
</section>

It's a heuristic, not a guarantee

As clever as this is, it's still an algorithm counting pixels. It doesn't know that the most important thing in the photo is a tiny, bright red boat right behind the third word of your headline. So test it with a wide range of images, keep a subtle overlay as a safety net, and consider giving editors a way to override it, like a simple "Text color: Auto / Light / Dark" dropdown. Auto will be right most of the time, and when it isn't, the fix is one click away.

Check out the documentation for the full list of color functions. Your visitors with low vision will thank you, and so will everyone trying to read your headlines on a phone in the sun. 🥷🎨