417 lines
13 KiB
SCSS
417 lines
13 KiB
SCSS
/*
|
||
WCAG color contrast formula
|
||
https://www.w3.org/TR/2016/NOTE-WCAG20-TECHS-20161007/G18#G18-procedure
|
||
|
||
@see https://codepen.io/giana/project/full/ZWbGzD
|
||
|
||
This pen uses the non-standard Sass pow() function
|
||
https://css-tricks.com/snippets/sass/power-function/
|
||
Using it outside of CodePen requires you provide your own pow() function with support for decimals
|
||
|
||
To generate random colors, we're also using a two-variable random() function includded with compass.
|
||
*/
|
||
|
||
@function pow($number, $exponent) {
|
||
@if (round($exponent) != $exponent) {
|
||
@return exp($exponent * ln($number));
|
||
}
|
||
|
||
$value: 1;
|
||
|
||
@if $exponent > 0 {
|
||
@for $i from 1 through $exponent {
|
||
$value: $value * $number;
|
||
}
|
||
} @else if $exponent < 0 {
|
||
@for $i from 1 through -$exponent {
|
||
$value: $value / $number;
|
||
}
|
||
}
|
||
|
||
@return $value;
|
||
}
|
||
|
||
@function factorial($value) {
|
||
$result: 1;
|
||
|
||
@if $value == 0 {
|
||
@return $result;
|
||
}
|
||
|
||
@for $index from 1 through $value {
|
||
$result: $result * $index;
|
||
}
|
||
|
||
@return $result;
|
||
}
|
||
|
||
@function summation($iteratee, $input, $initial: 0, $limit: 100) {
|
||
$sum: 0;
|
||
|
||
@for $index from $initial to $limit {
|
||
$sum: $sum + call($iteratee, $input, $index);
|
||
}
|
||
|
||
@return $sum;
|
||
}
|
||
|
||
@function exp-maclaurin($x, $n) {
|
||
@return (pow($x, $n) / factorial($n));
|
||
}
|
||
|
||
@function exp($value) {
|
||
@return summation(exp-maclaurin, $value, 0, 100);
|
||
}
|
||
|
||
@function ln-maclaurin($x, $n) {
|
||
@return (pow(-1, $n + 1) / $n) * (pow($x - 1, $n));
|
||
}
|
||
|
||
@function ln($value) {
|
||
$ten-exp: 1;
|
||
$ln-ten: 2.30258509;
|
||
|
||
@while ($value > pow(10, $ten-exp)) {
|
||
$ten-exp: $ten-exp + 1;
|
||
}
|
||
|
||
@return summation(ln-maclaurin, $value / pow(10, $ten-exp), 1, 100) + $ten-exp * $ln-ten;
|
||
}
|
||
|
||
|
||
|
||
// Check if value is not a number, eg, NaN or Infinity
|
||
@function is-nan($value) {
|
||
@return $value / $value != 1;
|
||
}
|
||
|
||
// Constrain number between two values
|
||
@function clip($value, $min : 0.0001, $max : 0.9999) {
|
||
@return if($value > $max, $max, if($value < $min, $min, $value));
|
||
}
|
||
|
||
// Checks if value is within specified bounds, inclusive
|
||
@function in-bounds($value, $min : 0, $max : 1) {
|
||
@return if($value >= $min and $value <= $max, true, false);
|
||
}
|
||
|
||
//== Step one: Convert
|
||
|
||
// Returns an RGB channel processed as XYZ... or partly at least
|
||
// See w3.org link for formula
|
||
@function xyz($channel) {
|
||
$channel: $channel / 255;
|
||
|
||
@return if($channel <= 0.03928, $channel / 12.92, pow((($channel + 0.055) / 1.055), 2.4));
|
||
}
|
||
|
||
// Reverse of xyz(). Returns XYZ value to RGB channel
|
||
// https://en.wikipedia.org/wiki/SRGB
|
||
@function srgb($channel) {
|
||
@return 255 * if($channel <= 0.0031308, $channel * 12.92, 1.055 * pow($channel, 1/2.4) - 0.055);
|
||
}
|
||
|
||
//== Step two: Measure brightness
|
||
|
||
// Returns relative luminance of color
|
||
// See w3.org link for formula
|
||
@function luminance($color) {
|
||
$red: xyz(red($color));
|
||
$green: xyz(green($color));
|
||
$blue: xyz(blue($color));
|
||
|
||
@return $red * 0.2126 + $green * 0.7152 + $blue * 0.0722;
|
||
}
|
||
|
||
//== Step three: Check contrast
|
||
|
||
// Checks if two colors pass minimum contrast requirements, option to return ratio instead of true/false
|
||
// See w3.org link for formula
|
||
@function check-contrast($color1, $color2 : #fff, $min-ratio : 'AA', $return-ratio : false) {
|
||
// Accept keywords for ratio
|
||
@if($min-ratio == 'AA' or $min-ratio == 'AAALG') { $min-ratio: 4.5; }
|
||
@elseif($min-ratio == 'AALG') { $min-ratio: 3; }
|
||
@elseif($min-ratio == 'AAA') { $min-ratio: 7; }
|
||
|
||
// Check brightness of each color
|
||
$lum1: luminance($color1);
|
||
$lum2: luminance($color2);
|
||
|
||
// Measure contrast ratio
|
||
$ratio: (max($lum1, $lum2) + 0.05) / (min($lum1, $lum2) + 0.05);
|
||
|
||
// Return ratio if option set
|
||
@if($return-ratio) { @return $ratio; }
|
||
|
||
// Else return boolean
|
||
@return if($ratio >= $min-ratio, true, false);
|
||
}
|
||
|
||
//== Step four: Scale luminance and lightness
|
||
|
||
// Takes color, scales luminance, spits out new color
|
||
@function scale-luminance($color, $target-luminance) {
|
||
// First, scale the channels by the required amount
|
||
$scale: $target-luminance / luminance($color);
|
||
|
||
// And clip them, so we don't end up dividing by zero... among other things I forget
|
||
$red: clip(xyz(red($color))) * $scale;
|
||
$green: clip(xyz(green($color))) * $scale;
|
||
$blue: clip(xyz(blue($color))) * $scale;
|
||
|
||
// Sometimes, that's not enough and one channel hits #ff or #00. We'll need to scale the other channels to compensate
|
||
$red-passes: in-bounds($red);
|
||
$green-passes: in-bounds($green);
|
||
$blue-passes: in-bounds($blue);
|
||
|
||
@if(not $red-passes or not $green-passes or not $blue-passes) {
|
||
// First, pick a channel to be a baseline, so the rest can be expressed as ratios
|
||
$baseline: min($red, $green, $blue);
|
||
|
||
// Then set up the variables expressed in terms of the baseline
|
||
$r: $red / $baseline;
|
||
$g: $green / $baseline;
|
||
$b: $blue / $baseline;
|
||
|
||
// Subtract any channel no longer in bounds
|
||
//-- TODO This needs to DRY. how to dry. help
|
||
@if(not $red-passes) {
|
||
$target-luminance: $target-luminance - 0.2126;
|
||
$r: 0;
|
||
}
|
||
|
||
@if(not $green-passes) {
|
||
$target-luminance: $target-luminance - 0.7152;
|
||
$g: 0;
|
||
}
|
||
|
||
@if (not $blue-passes) {
|
||
$target-luminance: $target-luminance - 0.0722;
|
||
$b: 0;
|
||
}
|
||
|
||
// Now get the required difference by using the luminance() formula
|
||
$x: $target-luminance / ($r * 0.2126 + $g * 0.7152 + $b * 0.0722);
|
||
|
||
// And multiply the channels by this new per-channel luminance
|
||
@if($red-passes) { $red: $r * $x; }
|
||
@if($green-passes) { $green: $g * $x; }
|
||
@if($blue-passes) { $blue: $b * $x; }
|
||
}
|
||
|
||
// Return the new color
|
||
@return rgb(srgb($red), srgb($green), srgb($blue));
|
||
}
|
||
|
||
// Scales lightness by 0.1% while checking contrast ratio. This is just a last-ditch effort to correct rounding errors
|
||
@function scale-light($color1, $color2, $min-ratio, $operation, $iterations) {
|
||
// Loop this function for however many iterations are passed
|
||
@for $n from 1 through $iterations {
|
||
// Return color unchanged if it passes contrast check
|
||
@if(check-contrast($color1, $color2, $min-ratio)) {
|
||
@return $color1;
|
||
} @else {
|
||
// Otherwise use the built-in lighten() and darken() functions, which change the lightness channel (ie, the L in HSL)
|
||
// Our previous scale-luminance() function changes both saturation and lightness
|
||
$color1: if($operation == lighten, lighten($color1, 0.1%), darken($color1, 0.1%));
|
||
}
|
||
}
|
||
|
||
// Return the best color we've got
|
||
@return $color1;
|
||
}
|
||
|
||
//== Step six: Fix colors
|
||
|
||
// Tries to fix contrast by adjusting $color1
|
||
@function fix-color($color1, $color2 : #fff, $min-ratio : 'AA', $iterations : 5) {
|
||
// Accept keywords for ratio
|
||
@if($min-ratio == 'AA' or $min-ratio == 'AAALG') { $min-ratio: 4.5; }
|
||
@elseif($min-ratio == 'AALG') { $min-ratio: 3; }
|
||
@elseif($min-ratio == 'AAA') { $min-ratio: 7; }
|
||
|
||
// If check fails, begin conversion
|
||
@if(not check-contrast($color1, $color2, $min-ratio)) {
|
||
// First get both luminances and clip so #fff and #000 don't break anything
|
||
$lum1: clip(luminance($color1));
|
||
$lum2: clip(luminance($color2));
|
||
|
||
// Defaults we'll set later
|
||
$target-luminance: $lum1;
|
||
$operation: '';
|
||
|
||
// If the same luminance is passed, lighten/darken one to make conversion possible
|
||
@if($lum1 == $lum2) {
|
||
// Darken light colors and lighten dark colors, so we have more room to scale them (eg, we won't hit #fff or #000 before we can fix them)
|
||
@if($lum1 > 0.5) {
|
||
$color1: darken($color1, 1%);
|
||
$lum1: luminance($color1);
|
||
} @else {
|
||
$color1: lighten($color1, 1%);
|
||
$lum1: luminance($color1);
|
||
}
|
||
}
|
||
|
||
// Now let's get the target luminance. This basically reverses check-contrast(), so we know what luminance to aim for
|
||
@if(max($lum1, $lum2) == $lum1) {
|
||
$target-luminance: (($lum2 + 0.05) * $min-ratio - 0.05);
|
||
$operation: lighten;
|
||
} @else {
|
||
$target-luminance: (($lum2 + 0.05) / $min-ratio - 0.05);
|
||
$operation: darken;
|
||
}
|
||
|
||
// Skip the whole conversion if we just need #fff or #000
|
||
@if($target-luminance >= 1) { @return #fff; }
|
||
@elseif ($target-luminance <= 0) { @return #000; }
|
||
@else {
|
||
// Scale color by calculated difference to arrive at target luminance
|
||
$color1: scale-luminance($color1, $target-luminance);
|
||
|
||
// Try to fix any rounding errors by lightening or darkening
|
||
$color1: scale-light($color1, $color2, $min-ratio, $operation, $iterations);
|
||
}
|
||
|
||
}
|
||
|
||
// Tada
|
||
@return $color1;
|
||
}
|
||
|
||
// Tries to fix contrast of both colors by weighted balance (0–100)
|
||
// 0 = don't change first color, change second color;
|
||
// 100 = change first color, don't change second color
|
||
@function fix-contrast($color1, $color2, $min-ratio : 'AA', $balance : 50) {
|
||
@if(not check-contrast($color1, $color2, $min-ratio)) {
|
||
// Fix colors
|
||
$color-fixed-1: fix-color($color1, $color2, $min-ratio);
|
||
$color-fixed-2: fix-color($color2, $color1, $min-ratio);
|
||
|
||
// We're just fixing both colors, then mixing back the original color using the native Sass function. Easy-peasy
|
||
$color1: mix($color-fixed-1, $color1, $balance);
|
||
$color2: mix($color2, $color-fixed-2, $balance);
|
||
|
||
// If the current configuration doesn't work, try to fix it
|
||
@if (not check-contrast($color1, $color2, $min-ratio)) {
|
||
// This happens if, again, we reach #fff or #000 before we want to
|
||
@if(not in-bounds(luminance($color-fixed-2), 0.00002, 0.99936)) {
|
||
// So we scale the opposite color to compensate
|
||
$color1: fix-color($color1, $color2, $min-ratio);
|
||
@warn "Your settings didn't work. Modifying first color in an attempt to fix."
|
||
}
|
||
@if(not in-bounds(luminance($color-fixed-1), 0.00002, 0.99936)) {
|
||
$color2: fix-color($color2, $color1, $min-ratio);
|
||
@warn "Your settings didn't work. Modifying second color in an attempt to fix."
|
||
}
|
||
}
|
||
}
|
||
|
||
// Returns a list with both colors, use nth($result, 1) and nth($result, 2) to get colors. See below for example
|
||
@return $color1, $color2;
|
||
}
|
||
|
||
// Get the best contrast when given three colors
|
||
@function best-contrast($color, $color1, $color2, $ratio1 : 'AA', $ratio2 : $ratio1) {
|
||
@if(not check-contrast($color, $color1, $ratio1) or not check-contrast($color, $color2, $ratio2)) {
|
||
// First get the luminance of the two static colors
|
||
$lum1: luminance(fix-color($color1, $color1, $ratio1));
|
||
$lum2: luminance(fix-color($color2, $color2, $ratio2));
|
||
|
||
// Average the luminance together to get the maximum difference
|
||
$average-lum: ($lum1 + $lum2) / 2;
|
||
|
||
// Then set changing color to this luminance
|
||
$color: scale-luminance($color, $average-lum);
|
||
|
||
// Warn if it fails contrast check
|
||
@if(not check-contrast($color, $color1, $ratio1)) {
|
||
@warn 'Your color fails to contrast with #{$color1}';
|
||
}
|
||
|
||
@if(not check-contrast($color, $color2, $ratio2)) {
|
||
@warn 'Your color fails to contrast with #{$color2}';
|
||
}
|
||
}
|
||
|
||
@return $color;
|
||
}
|
||
|
||
|
||
//====== Helper functions
|
||
|
||
@function randomColor() {
|
||
$color: hsl(random(360), random(100), random(100));
|
||
@return $color;
|
||
}
|
||
|
||
@mixin show-color($color) {
|
||
background: $color;
|
||
color: if(luminance($color) > 0.55, #000, #fff);
|
||
|
||
&::after {
|
||
content: '#{$color}';
|
||
}
|
||
}
|
||
|
||
//====== Put in your own settings here
|
||
$ratio: random(21); // A number between 1 and 21
|
||
$balance: random(100); // A number between 0 and 100
|
||
|
||
// Any valid color
|
||
$color1: randomColor();
|
||
$color2: scale-luminance(randomColor(), luminance($color1) + 0.1);
|
||
$color3: randomColor();
|
||
|
||
.ratio::after { content: '#{$ratio}'; }
|
||
.balance::after { content: '#{$balance}'; }
|
||
|
||
.color-block .color1 { @include show-color($color1); }
|
||
.color-block .color2 { @include show-color($color2); }
|
||
|
||
.fix-color {
|
||
.color:nth-child(2) { @include show-color(fix-color($color1, $color2, $ratio)); }
|
||
.color:nth-child(3) { @include show-color(fix-color($color2, $color1, $ratio)); }
|
||
}
|
||
|
||
.fix-contrast {
|
||
.color:nth-child(2) { @include show-color(nth(fix-contrast($color1, $color2, $ratio, $balance),1)); }
|
||
.color:nth-child(3) { @include show-color(nth(fix-contrast($color1, $color2, $ratio, $balance),2)); }
|
||
}
|
||
|
||
.best-contrast {
|
||
.color:nth-child(2) { @include show-color($color3); }
|
||
.color:nth-child(3) { @include show-color(best-contrast($color3, $color1, $color2, $ratio, $ratio)); }
|
||
}
|
||
|
||
.scale-luminance {
|
||
.color:nth-child(2) { @include show-color(scale-luminance($color1, luminance($color2))); }
|
||
}
|
||
|
||
.check-contrast {
|
||
.result::after { content: '#{check-contrast($color1, $color2, $ratio)}' ;}
|
||
}
|
||
|
||
.luminance {
|
||
.result::after { content: '#{luminance($color1), luminance($color2)}' ;}
|
||
}
|
||
|
||
|
||
// Amplify (strengthen) color by percentage
|
||
// @see https://www.scrivito.com/blog/sass-magic
|
||
@function amplify($color, $percentage) {
|
||
@if (lightness( $color ) <= 50) {
|
||
@return darken($color, $percentage);
|
||
}
|
||
@else {
|
||
@return lighten($color, $percentage);
|
||
}
|
||
}
|
||
// Diminish (weaken) color by percentage
|
||
@function diminish($color, $percentage) {
|
||
@if (lightness( $color ) >= 50) {
|
||
@return darken($color, $percentage);
|
||
}
|
||
@else {
|
||
@return lighten($color, $percentage);
|
||
}
|
||
} |