0
votes

I am trying to create a Sass function that receives a foreground color and background color and calculates the contrast ratio. From there (and the part I'm stuck on) is that it would simply return the foreground color if it meets the target contrast ratio, but if it doesn't it would lighten or darken the foreground color to meet the target contrast ratio.

For example, if the background supplied was #000 and the foreground supplied was #444 (a contrast ratio of 2.15), this function would lighten the foreground to #757575 and return that color.

I've got everything working except for the part where I need to reverse the contrast calculation. My initial thought was to approach it with what percentage it was away from target and simply lighten/darken (depending on which color was originally darker) by 100 minus the percent difference. This approach, in hindsight, was a little naive and I'm afraid some more advanced math will be involved.

Here is what I created so far (and here is a simplified fiddle):

@function wcag-color($bg, $fg, $size: 16px, $level: "aa"){
    @if ( $level == "aa" ){
        $wcag_contrast_ratio: 4.5; //For text smaller than 18px
        @if ( $size >= 19  ){
            $wcag_contrast_ratio: 3; //For text larger than 19px
        }
    }

    @if ( $level == "aaa" ){
        $wcag_contrast_ratio: 7; //For text smaller than 18px
        @if ( $size >= 19  ){
            $wcag_contrast_ratio: 4.5; //For text larger than 19px
        }
    }

    $actual_contrast_ratio: contrast($bg, $fg); //This function returns the contrast between the two colors.

    @if ( $actual_contrast_ratio > $wcag_contrast_ratio ){
        @return $fg; //Foreground color is acceptable
    }

    //Scale the lightness of the foreground to meet requested WCAG contrast ratio
    $difference: 100 - $actual_contrast_ratio / $wcag_contrast_ratio * 100; //There is more to it than this...

    //Edit: here are a few new lines to ponder. This assumes BG is darker than FG (would need to add a condition to compare luminance of each).
    $acceptable_luminance: luminance($bg)*$wcag_contrast_ratio; //What the luminance of the FG must be to comply
    $difference: ($acceptable_luminance - luminance($fg)); //How far away the FG luminance actually is (not sure if this helps anything...)

    @return scale-color($fg, $lightness: $difference); //Unfortunately luminance is not the same as lightness.
}

Notice the commented line "There is more to it than this..." – that is where I need to reverse my contrast formula, but I'd love if there was a simpler formula to use since I already know what the target contrast ratio is.

I've been thinking about this for a few days an I'm stumped. I'd prefer to avoid a guess-and-check method by looping through 1% lightened/darkened colors and testing each individually for their contrast ratio– that would work, but I'm sure there is a more optimal solution.

This was my reference for my initial functions (contrast and luminance) and was very helpful: https://medium.com/dev-channel/using-sass-to-automatically-pick-text-colors-4ba7645d2796

Note: I am not using Compass or any other Sass libraries.

Edit: Here is a simplified fiddle for reference: https://www.sassmeister.com/gist/445836123feb42885a0cf7f4709261ff

1

1 Answers

2
votes

So given #000000 and #444444, you can calculate the contrast ratio (2.15 in this case). The math is pretty straightforward, albeit a little hairy. (See the "relative luminance" definition.)

Now you want to go backwards? If you have #000000 and want a ratio of 4.5, starting with #444444, what should the color be? Is that what

I need to reverse my contrast formula

means?

It's a little complicated because you're solving for 3 variables, the red, green, and blue components, plus the luminance formula doesn't treat the red, green and blue equally. It's using 21.25% red, 71.5% green, and 7.25% blue.

Plus, the luminance formula isn't a simple linear formula so you can't just take a percentage short of luminance and bump the color value by that same percentage.

For example, in your case, the ratio was 2.15 but you need it to be 4.5. 2.15 is 108% short of 4.5, the desired value.

However, if you look at your original RGB values #444444 and you calculated it needed to be #757575 (in order to have a 4.5 ratio), then if you treat those RGB values as simple numbers (and convert to decimal), then #444444 (4473924) is 72% short of #757575 (7697781).

So you have a disconnect that your ratio is short by 108% but your RGB values are short by 72%. Thus you can't do a simple linear equation.

(The numbers aren't quite exact since #757575 gives you a 4.56 ratio, not an exact 4.5 ratio. If you use #747474, you get a 4.49 ratio, which is just a smidge too small for WCAG compliance but is closer to 4.5 than 4.56. However, #444444 is 71% short of #747474, so it's still not the same as 2.15 being 108% short of 4.5, so the basic concept still applies.)

Just for fun, I looked at the values of 0x11111 through 0x666666, incrementing by 0x111111, and calculated the contrast ratio. There weren't enough points on the graph so I added a color halfway between 0x111111 and 0x222222, then halfway between 0x222222 and 0x333333, etc.

RGB     contrast  % from 4.5  % from 0x747474
111111    1.11      305.41%       582.35%
191919    1.19      278.15%       364.00%
222222    1.32      240.91%       241.18%
2a2a2a    1.46      208.22%       176.19%
333333    1.66      171.08%       127.45%
3b3b3b    1.87      140.64%        96.61%
444444    2.16      108.33%        70.59%
4c4c4c    2.45       83.67%        52.63%
555555    2.82       59.57%        36.47%
5d5d5d    3.19       41.07%        24.73%
666666    3.66       22.95%        13.73%
6e6e6e    4.12        9.22%         5.45%

graph of contrast ratios

As you can see, the lines interset at the 3rd data point then converge toward each other. I'm sure there's a formula in there so you could take the contrast percentage, do some (probably logarithmic) function on it and get the percentage needed to change the color.

This would be a fascinating math problem that I currently don't have time to play with.

Update Jan 18, 2019

I got it to work going backwards, but it doesn't handle edge cases such as when making a dark color darker but you've already reached the max (or a light color lighter but you reached the max). But maybe you can play with it.

Test case

  • #ee0add for the light color (magenta-ish)
  • #445566 for the dark color (dark gray)
  • contrast ratio 2.09

When computing the "relative luminance" of a color, it has a conditional statement.

if X <= 0.03928 then 
  X = X/12.92 
else 
  X = ((X+0.055)/1.055) ^ 2.4

Before X is used in that condition, it's divided by 255 to normalize the value between 0 and 1. So if you take the conditional value, 0.03928, and multiply by 255, you get 10.0164. Since RGB values must be integers, that means an RGB component of 10 (0x0A) or less will go through the "if" and anything 11 (0x0B) or bigger will go through the "else". So in my test case values, I wanted one of the color parts to be 10 (0x0A) (#EE0ADD).

The relative luminance for #ee0add is 0.23614683378171950172526363525113 (0.236)

The relative luminance for #445566 is 0.0868525191131797135799815832377 (0.0868)

The "contrast ratio" is (0.236 + .05) / (0.0868 + .05) = 2.09

(You can verify this ratio on https://webaim.org/resources/contrastchecker/?fcolor=EE0ADD&bcolor=445566)

If we want a ratio of 4.5, and we want #ee0add to not change, then we have to adjust #445566. That means you need to solve for:

4.5 = (0.236 + .05) / (XX + .05)

So the second luminance value (XX) needs to be 0.01358818528482655593894747450025 (0.0136)

The original second luminance value was 0.0868525191131797135799815832377 (0.0868), so to get 0.01358818528482655593894747450025 (0.0136), we need to multiply the original by 0.15645125119651910313960717062698 (0.0136 / 0.0868 = 0.156) (or 15.6% of the original value)

If we apply that 15.6% to each of the R, G, and B relative luminance values, and then work through the conditional statement above backwards, you can get the RGB values.

Original luminance for #445566

r = 0x44 = 68 
g = 0x55 = 85 
b = 0x66 = 102

r1 = 68  / 255 = 0.26666666666666666666666666666667
g1 = 85  / 255 = 0.33333333333333333333333333333333
b1 = 102 / 255 = 0.4

r2 = ((.267 + .055) / 1.055)^^2.4 = 0.05780543019106721120703816752337
g2 = ((.333 + .055) / 1.055)^^2.4 = 0.09084171118340767766490119106965
b2 = ((.400 + .055) / 1.055)^^2.4 = 0.13286832155381791428570549818868

l = 0.2126 * r2 + 0.7152 * g2 + 0.0722 * b2
  = 0.0868525191131797135799815832377

Working backwards, take 15.6% of the r2, g2, and b2 values

r2 = 0.05780543019106721120703816752337 * 15.6% = 0.00904373187934550551617004082875
g2 = 0.09084171118340767766490119106965 * 15.6% = 0.01421229937547695322310904970549
b2 = 0.13286832155381791428570549818868 * 15.6% = 0.02078741515147623990363062978804

Now undo that mess with ^^2.4 and the other stuff

  • to undo X^^2.4 you have to do the inverse, X^^(1/2.4) or X^^(0.4167)
  • then multiply by 1.055
  • then subtract 0.055
  • then multiply by 255
pow( 0.00904373187934550551617004082875, 1/2.4) = 0.14075965680504652191078668676178
pow( 0.01421229937547695322310904970549, 1/2.4) = 0.16993264267137740728089791717873
pow( 0.02078741515147623990363062978804, 1/2.4) = 0.19910562853770829265100914759565

multiply by 1.055

0.14075965680504652191078668676178 * 1.055 = 0.14850143792932408061587995453368
0.16993264267137740728089791717873 * 1.055 = 0.17927893801830316468134730262356
0.19910562853770829265100914759565 * 1.055 = 0.21005643810728224874681465071341

subtract 0.055

0.14850143792932408061587995453368 - 0.055 = 0.09350143792932408061587995453368
0.17927893801830316468134730262356 - 0.055 = 0.12427893801830316468134730262356
0.21005643810728224874681465071341 - 0.055 = 0.15505643810728224874681465071341

multiply by 255

0.09350143792932408061587995453368 * 255 = 23.842866671977640557049388406088 = 24 = 0x18
0.12427893801830316468134730262356 * 255 = 31.691129194667306993743562169008 = 32 = 0x20
0.15505643810728224874681465071341 * 255 = 39.53939171735697343043773593192  = 40 = 0x28

So the darker color is #182028. There are probably some rounding errors but if you check the original foreground color, #ee0add with the new color, #182028, you get a contrast ratio of 4.48. Just shy of 4.5, but like I said, probably some rounding errors.

https://webaim.org/resources/contrastchecker/?fcolor=EE0ADD&bcolor=182028

I tried doing the same thing with #ee0add, keeping #445566 the same, but when going backwards and getting to the last step where you multiply by 255, I got numbers greater than 255, which are not valid RGB components (they can only go up to 0xFF). If I stopped the number at 255 then took the difference and added it to the smallest color value, I got a decent color but the ratio was 5.04, overshooting 4.5. I can post that math too if you want.