Skip to main content

Using Functions to Leverage Accessible Color Contrast with User-Entered Values

Front-end Development
Accessibility
Drupal

Working with a CMS can give a lot of power over layout and styles to content admins. But that kind of power can be easy to abuse if you don't have the appropriate knowledge to back it up. This issue is common in the realm of accessibility, with problems that range from lack of unique links to bad (or missing) alternative text. And that's just related to content. What if you give your admins the power to control the colors of their website? This can be a big red flag for accessibility if done wrong, or if free rein is given without the knowledge of consequences behind user-entered choices. But, as developers, there are protections we can put in place to help guide accessible solutions.

While there are several methods to prevent contrast issues on user-entered content, such as adding a contrast evaluator to different color fields and preventing poor contrasts from being saved, we're going to take a look at a method in Drupal (within a custom module) that seeks to ease the burden on admins to make dozens of color decisions, and instead leverage a fewer number of fields and evaluate them with functions to determine the best text color.

First, we have our color values that we've gathered from user-entered content (a process for this is covered in Creating CSS Variables from User-Entered Field Values). In this particular case, we haven't asked for any text colors, but have focused on items like background colors. In this example, we are supporting three different background colors in both light mode and dark mode, so six in total:

// Light mode colors.
    $lm_light_bgrd_color = $entity->field_lm_light_bgrd_color->color ?? '#FAFAFA';
    $lm_dark_bgrd_color = $entity->field_lm_dark_bgrd_color->color ?? '#2C302E';
    $lm_bright_bgrd_color = $entity->field_lm_bright_bgrd_color->color ?? '#D0FED5';

// Dark mode colors.
    $dm_light_bgrd_color = $entity->field_dm_light_bgrd_color->color ?? '#FFFFFF';
    $dm_dark_bgrd_color = $entity->field_dm_dark_bgrd_color->color ?? '#111412';
    $dm_bright_bgrd_color = $entity->field_dm_bright_bgrd_color->color ?? '#A3FEAB';

As you can see, we created variables for each color, as well as a fallback for each if none is entered. This helps ensure there is always a value we can work with, although you could also accomplish this by setting a default value for the field with in Drupal's UI. By collecting these values in the UI, we free up the content admin to be able to plug (or later update) their brand's colors without requiring additional work from a developer.

But how do we know what text color to use for each of these? We can't guarantee that the three light mode background colors will be, well...light. Same with the dark mode colors. So we can't assume that we should use dark text in light mode and light text in dark mode. Nor do we want to double the amount of variables the user has to enter to come up with a color combination for each background (Although if you want to give your users that granular level of power, you can).

So what can we do to ensure the color contrast between text and background, no matter what value is entered? This is where functions come in. There are Javascript functions out there that help evaluate color contrast, but that work is done after rendering. Since we're using Drupal, we're going to take a look at some PHP functions that can give us what we need right away.

<?php

namespace Drupal\smm_theme;

use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Functions for theme accessibility.
 */
class TextColor {
  use StringTranslationTrait;

  /**
   * Convert hex code to rgb value.
   */
  public function convertHexToRgb($hex) {
    // Sanitize $color if "#" is provided.
    $hex = ltrim($hex, '#');

    $hex_array = str_split($hex);

    $components = [
      $hex_array[0] . $hex_array[1],
      $hex_array[2] . $hex_array[3],
      $hex_array[4] . $hex_array[5],
    ];

    // Convert hex to rgb.
    $rgb = array_map('hexdec', $components);

    return $rgb;
  }

  /**
   * Determine whether text should be white or black based on background color.
   */
  public function getTextColor($hex) {
    $rgb = $this->convertHexToRgb($hex);

    // Calculation pulled from
    // https://mixable.blog/black-or-white-text-on-a-colour-background/.
    $weighted_color_distance = sqrt(pow($rgb[0], 2) * 0.241 + pow($rgb[1], 2) * 0.691 + pow($rgb[2], 2) * 0.068);

    $text_color = $weighted_color_distance > 127 ? 'black' : 'white';

    return $text_color;
  }
}

The first function, convertHexToRGB, is simply because we collected the values in a hex format. If you already have RGB values, then this particular conversion isn't necessary. The second function, getTextColor, is where the magic happens. Here we have a calculation for $weighted_color_distance, which takes into account visual perceptions of color and not just straight arithmetic, like the way some color evaluations look at L value in HSL colors. If you want to read the explanation behind this math, check out this Mixable article about white or black text on a colored background. Once we have our weighted color distance, which should give us a number between 0 and 255, we simply check if its value is greater than 127 (or in the upper half) and determine if we want our text color to be white or black. The higher the number, the lighter the background. Easy peasy, right?

But wait, now do we take this and turn it into something usable from a theme perspective? We have to run those values through our function, of course.

// Light sections in light mode.
    $styles['lm_light_bgrd_color'] = $lm_light_bgrd_color;
    $styles['lm_light_text_color'] = $service->getTextColor($lm_light_bgrd_color);

// Dark sections in light mode.
    $styles['lm_dark_bgrd_color'] = $lm_dark_bgrd_color;
    $styles['lm_dark_text_color'] = $service->getTextColor($lm_dark_bgrd_color);

// Bright sections in light mode.
    $styles['lm_bright_bgrd_color'] = $lm_bright_bgrd_color;
    $styles['lm_bright_text_color'] = $service->getTextColor($lm_bright_bgrd_color);

// Light sections in dark mode.
    $styles['dm_light_bgrd_color'] = $dm_light_bgrd_color;
    $styles['dm_light_text_color'] = $service->getTextColor($dm_light_bgrd_color);

// Dark sections in dark mode.
    $styles['dm_dark_bgrd_color'] = $dm_dark_bgrd_color;
    $styles['dm_dark_text_color'] = $service->getTextColor($dm_dark_bgrd_color);

// Bright sections in dark mode.
    $styles['dm_bright_bgrd_color'] = $dm_bright_bgrd_color;
    $styles['dm_bright_text_color'] = $service->getTextColor($dm_bright_bgrd_color);

As you can see, our background variables are simply grabbing our first user entered colors from before. But then we created our text color variables by taking each color and running it through our functions to return whether the text should be black or white. And there you have it! No matter the background color, in light mode or in dark mode, we can return the most accessible contrast combination for it. We went on to make these into CSS variables that we could leverage in our theme, so changing a color value doesn't require developer intervention to evaluate or update the text color for accessibility. It's already been done!