<h1>Automated Color Contrast Ratios</h1>
<p>Input a foreground color, background color, and desired WCAG Level to get back an adjusted foreground color that passes the chosen WCAG Level for color contrast ratio.</p>

<pre>
  // Usage, where: 
  // $text-color is the desired color which will be changed
  // $background is the unchanged color
  // $level is the WCAG level desired, default 'AA'
  // $size is the font-size to consider, default 16
  // $bold is whether or not the font is bold, default false
  .example {
    background-color: $background;
    color: a11y-color($text-color, background, $level, $size, $bold);
  }
</pre>

<div class="color">
  <div class="color__swatch color__swatch--fg1">fg-1</div>
  <div class="color__swatch color__swatch--fg2">fg-2</div>
  <div class="color__swatch color__swatch--bg1">bg-1</div>
  <div class="color__swatch color__swatch--bg2">bg-2</div>
  <div class="color__swatch color__swatch--bg3">bg-3</div>
</div>

<div class="ally">
  <div class="ally__item ally-fg1-bg1">
    <p>BEFORE: Foreground 1 over background 1: AAA target, 16px text, not bold</p>
  </div>
  <div class="ally__item pair-fg1-bg1">
    <p>AFTER: Foreground 1 over background 1: AAA target, 16px text, not bold</p>
  </div>
  
  <div class="ally__item ally-fg1-bg2">
    <p style="font-size: 19px;"><b>BEFORE: Foreground 1 over background 2: AA target, 19px text, bold</b></p>
  </div>
  <div class="ally__item pair-fg1-bg2">
    <p style="font-size: 19px;"><b>AFTER: Foreground 1 over background 2: AA target, 19px text, bold</b></p>
  </div>
  
  <div class="ally__item ally-fg1-bg3">
    <p>BEFORE: Foreground 1 over background 3: AA target, 16px text, not bold</p>
  </div>
  <div class="ally__item pair-fg1-bg3">
    <p>AFTER: Foreground 1 over background 3: AA target, 16px text, not bold</p>
  </div>
  
  <div class="ally__item ally-fg2-bg1">
    <p>BEFORE: Foreground 2 over background 1: AA target, 16px text, not bold</p>
  </div>
  <div class="ally__item pair-fg2-bg1">
    <p>AFTER: Foreground 2 over background 1: AA target, 16px text, not bold</p>
  </div>
  
  <div class="ally__item ally-fg2-bg2">
    <p style="font-size: 19px;">BEFORE: Foreground 2 over background 2: AAA target, 19px text, not bold</p>
  </div>
  <div class="ally__item pair-fg2-bg2">
    <p style="font-size: 19px;">AFTER: Foreground 2 over background 2: AAA target, 19px text, not bold</p>
  </div>
  
  <div class="ally__item ally-fg2-bg3">
    <p>BEFORE: Foreground 2 over background 3: AA target, 16px text, not bold</p>
  </div>
  <div class="ally__item pair-fg2-bg3">
    <p>AFTER: Foreground 2 over background 3: AA target, 16px text, not bold</p>
  </div>
</div>

<h2>Explanation</h2>
<p>An accessible Color Contrast is important for legibility. Often, a company’s brand color palette does not take this into account. Further, the color behind a foreground text color will effect its ability to pass for sufficient contrast ratio. This set of functions provides programmatic SASS to achieve passing color contrast tests. It allows the theme engineer to pass colors and optional parameters to get the results that pass the desired threshold. Sensible defaults are in place. </p>

<h2>More about WCAG Color Contrast levels</h2>
<p>WCAG 2.1 guidelines dictate a minimum contrast ratio of 4.5:1 for the visual presentation of both text and images embedded as text. Large text (18 point, 14 point if bold, and larger font sizes plus images of very large text) requires a 3:1 contrast ratio.</p>
<p>14 point text is equivalent to 19px. 18 point text is equivalent to 24px. Therefore:</p>
<ul>
  <li>Level AA: Text 24px and larger <b>or 19px and larger if bold</b> should have a CCR of 3:1</li>
  <li>Level AA: Text 23px and smaller should have a CCR of 4.5:1</li>
  <li>Level AAA: Text 24px and larger <b>or 19px and larger if bold</b> should have a CCR of 4.5:1</li>
  <li>Level AAA: Text 23px and smaller should have a CCR of 7:1</li>
</ul>
<p>There are no contrast requirements for logotypes and incidental text — text that has no context to the website's core purpose. This would include text for decoration and iconography alongside descriptive text. Inactive elements — like a button in a disabled state — are identified by their low contrast and therefore are not required to meet minimum. </p>

<h2>Acknowledgments & Caveats</h2>
<p>The caveat is the build time required for this solution. POW is an expensive operation for SASS to perform. I have timed-out Sassmeister and this CodePen is slow to load. I have seen local build times jump from under a second to over 5 seconds, depending on how many colors need to be calculated. Possible solutions? Smarter caching? Lookup table (see below)?</p>
<p>Credit to Lea Verou and her <a hre="https://github.com/LeaVerou/contrast-ratio/blob/gh-pages/color.js">Color.js</a> for direction and others who provided <a href="http://www.sassmeister.com/gist/ad6e6771df050ff3727f">complicated math functions</a> and inspiration to try calculating luminance in multiple ways. Finding an efficient set of functions to compute decimal exponents (“POW” functions) was the most difficult. </p>
<p>Credit to <a href="https://codepen.io/davidhalford/pen/wlDxL">ideas like these</a> but I was not interested in simply returning a value of black or white (though that is much easier to accomplish).</p>
<p>Credit to Jonny Kates for this attempt at the calculations, which provided me with some good direction: <a href="https://medium.com/@jonnykates/automating-colour-contrast-ratios-with-sass-e201f3b52797">medium.com/@jonnykates/automating-colour-contrast-ratios-with-sass-e201f3b52797</a></p>
<p>Improvement idea: Save a lookup table for the luminance, since it is the only part of the calculation that requires the expensive POW functionality: <a href="https://medium.com/dev-channel/using-sass-to-automatically-pick-text-colors-4ba7645d2796">medium.com/dev-channel/using-sass-to-automatically-pick-text-colors-4ba7645d2796</a></p>
@function strip-unit($value) {
  @return ($value / ($value * 0 + 1));
}

// Math functions from https://www.sassmeister.com/gist/5bbe8480c48e2fc10ab5
$PI: 3.141592653589793;

@function -math-exp-taylor-0($x, $steps) {
  $item: 1;
  $result: 1;
  @for $i from 1 to $steps {
    $item: $item * $x / $i;
    $result: $result + $item;
  }
  @return $result;
}

@function -math-ln-taylor-1($x, $steps) {
  $z: ($x - 1) / ($x + 1);
  $power: $z;
  $result: $z;
  @for $i from 1 to $steps {
    $power: $power * $z *$z;
    $result: $result + $power / (2 * $i + 1);
  }
  @return 2 * $result;
}

@function -math-sin-taylor-0($x, $steps) {
  $item: $x;
  $result: $x;
  @for $i from 1 to $steps {
    $item: -$item * $x * $x / (2 * $i) / (2 * $i + 1);
    $result: $result + $item;
  }
  @return $result;
}

@function -math-pow-int($base, $exponent) {
  @if $exponent < 0 {
    @return 1 / -math-pow-int($base, -$exponent);
  } @else if $exponent == 0 {
    @return 1;
  } @else if $exponent == 1 {
    @return $base;
  } @else {
    $exp: floor($exponent / 2);
    $pow: -math-pow-int($base, $exp);
    @if $exp * 2 == $exponent {
      @return $pow * $pow;
    } @else {
      @return $pow * $pow * $base;
    }
  }
}

@function -math-log-approx($x) {
  @if $x <= 0 {
    @error "cannot calculate log of #{$x}";
  } @else if $x >= 1 {
    // choose the smaller option (-1) because it yields better results in ln().
    @return str-length(inspect(round($x))) - 1;
  } @else {
    @return -1 * str-length(inspect(round(1 / $x)));
  }
}

@function ln($x, $steps: 32) {
  $ln10: 2.302585092994046;
  $approx: -math-log-approx($x);
  // $y is in range [1, 10]
  $y: $x / -math-pow-int(10, $approx);
  @return $approx * $ln10 + -math-ln-taylor-1($y, $steps);
}

@function pow($x, $exponent, $steps: 32) {
  $exp1: round($exponent);
  $exp2: $exponent - $exp1;
  $pow1: -math-pow-int($x, $exp1);
  @if $exp2 == 0 {
    @return $pow1;
  } @else {
    $y: ln($x, $steps) * $exp2;
    $pow2: -math-exp-taylor-0($y, $steps);
    @return $pow1 * $pow2;
  }
}

@function sqrt($x, $exponent: 2, $steps: 32) {
  @return pow($x, 1 / $exponent, $steps);
}
// End Math functions

// Adapted from: https://github.com/LeaVerou/contrast-ratio/blob/gh-pages/color.js
// Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
@function luminance($color) {
  // White luminance is 1
  // Black luminance is 0
  // #c00 luminance is 0.1283736919
  $rgba: red($color), green($color), blue($color);
  $rgba2: ();
  @for $i from 1 through 3 {
    $rgb: nth($rgba, $i);
    $rgb: $rgb / 255;
    $rgb: if($rgb < .03928, $rgb / 12.92, pow(($rgb + .055) / 1.055, 2.4));
    $rgba2: append($rgba2, $rgb);
  }
  @return (0.2126 * nth($rgba2, 1)) + (0.7152 * nth($rgba2, 2)) + (0.0722 * nth($rgba2, 3));
}

// Evaluate the Color luminance to determine if it is light or dark
@function yiq($color) {
  $r: red($color);
  $g: green($color);
  $b: blue($color);
  $yiq: (($r*299) + ($g*587) + ($b*114))/1000;
  @return $yiq;
}

@function light-or-dark($color) {
  // If YIQ is more than 128, this is a light color
  @if yiq($color) > 128 {
    @return "light";
  } @else {
    @return "dark";
  }
}

// Calculate "readability" as defined by WCAG 2.1
@function color-contrast($fg, $bg) {
  // Adapted from: https://github.com/LeaVerou/contrast-ratio/blob/gh-pages/color.js
  // Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
  $luminance1: luminance($fg) + 0.05;
  $luminance2: luminance($bg) + 0.05;
  $ratio: $luminance1 / $luminance2;
  @if $luminance2 > $luminance1 {
    $ratio: 1 / $ratio;
  }
  // Round to a hundreth because 6.96 should not pass a ratio of 7.0 (I saw it happen)
  $ratio: round($ratio * 100) / 100;
  @return $ratio;
}

@function validate-font-size($size) {
  // Check if any unit other than px
  @if unit($size) == 'em' or unit($size) == 'rem' {
    // Need to convert to a pixel value
    // Let's not overcomplicate it with possible EM inheritence scale factors
    @return strip-unit($size/16)
  }
  @if unit($size) == 'px' {
    // We expect PX, so strip the value and return it
    @return strip-unit($size);
  }
  @if unit($size) == '' {
    @return $size;
  }
}

@function get-ratio($level: 'AA', $size: 16, $bold: false) {
  // Default ratio
  $ratio: 4.5;
  @if $level == 'AAA' {
    $ratio: 7;
  }
  
  // Check font size
  @if $size < 24 {
    // Small text, use defaults
    // But:
    @if $size >= 19 and $bold == true {
      // Special case: Small text but also bold
      @if $level == 'AAA' {
        $ratio: 4.5;
      } @else {
        $ratio: 3;
      }
    }
  } @else {
    // Larger than 24
    $ratio: 3;
    @if $level == 'AAA' {
      $ratio: 4.5;
    }
  }
  @return $ratio;
}

@function a11y-color($fg, $bg, $level: 'AA', $size: 16, $bold: false) {
  // Goal: Return a color that passes for the chosen accessibility level
  // without changing the Hue of the color by more than 5%
  
  $font-size: strip-unit($size);
  $ratio: get-ratio($level, $font-size, $bold);
  
  // If we pass ratio already, return the original color
  @if color-contrast($fg, $bg) >= $ratio {
    @return $fg;
  } @else {
    // Should the color be lightened or darkened?
    // Higher percentage steps means faster compile time, but we might overstep the required threshold
    $step: 1%;
    $light-step: $step;
    // In addition to increasing/decreasing lightness, move the needle on saturation as well
    $sat-step: 1%;
    $fg-yiq: light-or-dark($fg);
    $bg-yiq: light-or-dark($bg);
    @if $fg-yiq == light and $bg-yiq == light {
      // Both are light colors, darken the fg
      $light-step: - $step;
    } @else if $fg-yiq == dark and $bg-yiq == light {
      // bg is light, fg is dark but does not pass, darken more
      $light-step: - $step;
    }
    // Keeping the rest of the logic here, but since our default values do not change, these are not needed
    //@else if $fg-yiq == light and $bg-yiq == dark {
    //  // bg is dark, fg is light but does not pass, lighten further
    //  $light-step: $step;
    //} @else if $fg-yiq == dark and $bg-yiq == dark {
    //  // both are dark, so lighten the fg
    //  $light-step: $step;
    //}
    
    // @while continues to evaluate until the expression is FALSE
    @while color-contrast($fg, $bg) < $ratio {
      $fg: scale-color($fg, $lightness: $light-step, $saturation: $sat-step);
    }
    @return $fg;
  }
}

///
/// ! - - - Styles for the Demo Color Values - - - 
///

// Colors to test with
$fg-1: #0094c2; // 3.49 over white
$fg-2: #E35B34; // 3.61 over white
$bg-1: #fff;
$bg-2: #f0f0f0;
$bg-3: #ababab;

.color {
  display: flex;
  flex-flow: row nowrap;

  &__swatch {
    padding: 1.5rem;
    border: 0.5px solid #101010;
    position: relative;
  
    &--fg1 {
      background-color: $fg-1;
      
      &::after { content: ' #{$fg-1}'; }
    }
    
    &--fg2 {
      background-color: $fg-2;
      
      &::after { content: ' #{$fg-2}'; }
    }
    
    &--bg1 {
      background-color: $bg-1;
      
      &::after { content: ' #{$bg-1}'; }
    }
    
    &--bg2 {
      background-color: $bg-2;
      
      &::after { content: ' #{$bg-2}'; }
    }
    
    &--bg3 {
      background-color: $bg-3;
      
      &::after { content: ' #{$bg-3}'; }
    }
  }
}

.ally {
  border: 0.5px solid #101010;
  
  &__item {
    padding: 1rem 2rem 1rem 1rem;
    position: relative;
    
    p {
      margin: 0;
    }
    
    &::after {
      font-size: 0.75rem;
      font-weight: bold;
      position: absolute;
      top: 50%;
      right: 0.5rem;
      transform: translateY(-50%);
    }
  }
}


.ally-fg1-bg1 {
  background-color: $bg-1;
  color: $fg-1;
  
  &::after {
    content: '#{color-contrast($fg-1, $bg-1)}';
  }
}

.pair-fg1-bg1 {
  background-color: $bg-1;
  $a11y-color: a11y-color($fg-1, $bg-1, 'AAA');
  color: $a11y-color;
  
  &::after {
    content: '#{color-contrast($a11y-color, $bg-1)}';
  }
}

.ally-fg1-bg2 {
  background-color: $bg-2;
  color: $fg-1;
  
  &::after {
    content: '#{color-contrast($fg-1, $bg-2)}';
  }
}

.pair-fg1-bg2 {
  background-color: $bg-2;
  $a11y-color: a11y-color($fg-1, $bg-2, 'AA', 19, true);
  color: $a11y-color;
  
  &::after {
    content: '#{color-contrast($a11y-color, $bg-2)}';
  }
}

.ally-fg1-bg3 {
  background-color: $bg-3;
  color: $fg-1;
  
  &::after {
    content: '#{color-contrast($fg-1, $bg-3)}';
  }
}

.pair-fg1-bg3 {
  background-color: $bg-3;
  $a11y-color: a11y-color($fg-1, $bg-3);
  color: $a11y-color;
  
  &::after {
    content: '#{color-contrast($a11y-color, $bg-3)}';
  }
}

.ally-fg2-bg1 {
  background-color: $bg-1;
  color: $fg-2;
  
  &::after {
    content: '#{color-contrast($fg-2, $bg-1)}';
  }
}

.pair-fg2-bg1 {
  background-color: $bg-1;
  $a11y-color: a11y-color($fg-2, $bg-1);
  color: $a11y-color;
  
  &::after {
    content: '#{color-contrast($a11y-color, $bg-1)}';
  }
}

.ally-fg2-bg2 {
  background-color: $bg-2;
  color: $fg-2;
  
  &::after {
    content: '#{color-contrast($fg-2, $bg-2)}';
  }
}

.pair-fg2-bg2 {
  background-color: $bg-2;
  $a11y-color: a11y-color($fg-2, $bg-2, 'AAA', 19, true);
  color: $a11y-color;
  
  &::after {
    content: '#{color-contrast($a11y-color, $bg-2)}';
  }
}

.ally-fg2-bg3 {
  background-color: $bg-3;
  color: $fg-2;
  
  &::after {
    content: '#{color-contrast($fg-2, $bg-3)}';
  }
}

.pair-fg2-bg3 {
  background-color: $bg-3;
  $a11y-color: a11y-color($fg-2, $bg-3);
  color: $a11y-color;
  
  &::after {
    content: '#{color-contrast($a11y-color, $bg-3)}';
  }
}


///
/// ! - - - Styles to Make the Demo Prettier - - - 
///
body {
  padding: 2em;
  font-family: sans-serif;
  line-height: 1.5;
}

h1 {
  margin-top: 0;
  padding-top: 0;
}

pre {
  $sage-green: #00552A;
  background-color: a11y-color(#9fdfbf, $sage-green);
  color: $sage-green;
  padding: 1.25rem 0.5rem;
}
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.