Automating WCAG Contrast Checks in Sass
Color contrast is easy to overlook until you actually test it. Run a site through the WebAIM Contrast Checker and you'll likely find combinations that looked fine but fail the WCAG thresholds. The fix isn't just about picking safer colors — it's about having a system that catches failures before they ship.
At Oomph, we've built a Sass function that automatically adjusts one color in a pair until the combination passes a given WCAG level. It handles the edge cases that slip past even careful design review: a one-off button background, a dark mode toggle, or a new color added to the system a year after launch.
What counts as accessible contrast
The WCAG criteria define contrast as a ratio between two colors. We default to Level AA, which requires:
- A Color Contrast Ratio (CCR) of 3.0:1 for text 24px and larger, or 19px and larger when bold.
- A CCR of 4.5:1 for smaller text.
Level AAA raises the bar:
- A CCR of 4.5:1 for text 24px and larger, or 19px and larger when bold.
- A CCR of 7:1 for smaller text.
You don't need to compute these ratios by hand — that's what the Sass does. But the thresholds matter because the function needs to know which one to target.
Start with the palette, not the code
Automation helps, but the first line of defense is the design. Our design team uses an internal tool that tests a list of colors against dark and light backgrounds, along with custom combinations:

Brand colors are rarely chosen with accessibility in mind. When they don't pass, we adjust them while keeping the intent of the color intact. The trick is preserving the "emerald-ness" of an emerald green while darkening it enough to pass over white.

We work in the HSL color model because it separates hue from saturation and lightness. Hue is the "soul" of the color — the 0° to 360° value on the color wheel, where green sits at 120°, cyan at 180°, and so on. Saturation runs from 0% (gray) to 100% (full color), and lightness from 0% (black) to 100% (white), with 50% meaning no added black or white. To make a color accessible, we keep the hue and shift the other two channels.

The problem we wanted to solve was not the palette itself but the edge cases. A designer can't reasonably test every combination that might appear in a large interface. New colors get added, and old ones resurface in unexpected contexts.
The case for a Sass solution
There are existing approaches that automatically choose between white and black text based on the background:
- Josh Bader's approach uses RGB values in CSS variables to calculate whether white or black has better contrast against a given color.
- Facundo Corradini's switch function does similar work with HSL values.
Neither suited our needs. We didn't want a binary white-or-black fallback; we wanted to tweak the color itself until it passed. Storing colors as split RGB or HSL components in CSS variables also felt too messy to maintain across a large codebase.
What we wanted was straightforward to express:
// Transform this non-passing color pair:
.example {
background-color: #444;
color: #0094c2; // a 2.79 contrast ratio when AA requires 4.5
font-size: 1.25rem;
font-weight: normal;
}
// To this passing color pair:
.example {
background-color: #444;
color: #00c0fc; // a 4.61 contrast ratio
font-size: 1.25rem;
font-weight: normal;
}
Given two colors, adjust one until the pair passes the WCAG threshold for the text size and weight in use. The function should decide which color changes, based on preference.
The math problem: Sass can't do exponents
The W3C provides the formula for computing contrast ratios. It involves multiplying RGB channels by perception-based weights and applying a gamma adjustment with a 2.4 exponent:
If L1 is the relative luminance of a first color
And L2 is the relative luminance of a second color, then
- Color Contrast Ratio = (L1 + 0.05) / (L2 + 0.05)
Where
- L = 0.2126 * R + 0.7152 * G + 0.0722 * B
And
- if R sRGB <= 0.03928 then R = R sRGB /12.92 else R = ((R sRGB +0.055)/1.055) ^ 2.4
- if G sRGB <= 0.03928 then G = G sRGB /12.92 else G = ((G sRGB +0.055)/1.055) ^ 2.4
- if B sRGB <= 0.03928 then B = B sRGB /12.92 else B = ((B sRGB +0.055)/1.055) ^ 2.4
And
- R sRGB = R 8bit /255
- G sRGB = G 8bit /255
- B sRGB = B 8bit /255
That exponent is the problem. Sass lacks native support for powers with decimal exponents. Most programming languages have something like JavaScript's math.pow(), but Sass doesn't.
An early version of the function used a series of workaround calculations to fake the math, sourcing functions from the community. It worked, but it made Sass build times climb exponentially — a few color checks could add minutes to production compilation. That wasn't viable.
The solution came from an unlikely place: a lookup table. As explained in a post by someone solving the same problem, the exponentiation only happens during the per-channel color space conversion. Since color channels have only 256 possible values each, you can precompute every result:
The only part [of the Sass that] involves exponentiation is the per-channel color space conversions done as part of the luminance calculation. [T]here are only 256 possible values for each channel. This means that we can easily create a lookup table.
That shift made the computation fast enough to be practical.
Using the function
The core function takes two colors, adjusts the first so it passes the specified WCAG level against the second, and returns a single color value. Optional parameters account for font size and boldness:
// @function a11y-color(
// $color-to-adjust,
// $color-that-will-stay-the-same,
// $wcag-level: 'AA',
// $font-size: 16,
// $bold: false
// );
// Sass sample usage declaring only what is required
.example {
background-color: #444;
color: a11y-color(#0094c2, #444); // a 2.79 contrast ratio when AA requires 4.5 for small text that is not bold
}
// Compiled CSS results:
.example {
background-color: #444;
color: #00c0fc; // which is a 4.61 contrast ratio
}
We used a function rather than a mixin because the output — a standalone color value — fits more naturally into a CSS rule. The author decides which color in the pair should change.
With all the parameters in place, a full call looks like:
// Sass
.example-2 {
background-color: a11y-color(#0094c2, #f0f0f0, 'AAA', 1.25rem, true); // a 3.06 contrast ratio when AAA requires 4.5 for text 19px or larger that is also bold
color: #f0f0f0;
font-size: 1.25rem;
font-weight: bold;
}
// Compiled CSS results:
.example-2 {
background-color: #087597; // a 4.6 contrast ratio
color: #f0f0f0;
font-size: 1.25rem;
font-weight: bold;
}
How it works
The main function orchestrates a set of helpers. The logic is documented inline, but the flow is: compute the current contrast of the pair against the target threshold, then adjust the color's lightness in steps until the ratio passes, while keeping hue intact.
// Expected:
// $fg as a color that will change
// $bg as a color that will be static and not change
// Optional:
// $level, default 'AA'. 'AAA' also accepted
// $size, default 16. PX expected, EM and REM allowed
// $bold, boolean, default false. Whether or not the font is currently bold
//
@function a11y-color($fg, $bg, $level: 'AA', $size: 16, $bold: false) {
// Helper: make sure the font size value is acceptable
$font-size: validate-font-size($size);
// Helper: With the level, font size, and bold boolean, return the proper target ratio. 3.0, 4.5, or 7.0 results expected
$ratio: get-ratio($level, $font-size, $bold);
// Calculate the first contrast ratio of the given pair
$original-contrast: color-contrast($fg, $bg);
@if $original-contrast >= $ratio {
// If we pass the ratio already, return the original color
@return $fg;
} @else {
// Doesn't pass. Time to get to work
// Should the color be lightened or darkened?
// Helper: Single color input, 'light' or 'dark' as output
$fg-lod: light-or-dark($fg);
$bg-lod: light-or-dark($bg);
// Set a "step" value to lighten or darken a color
// Note: Higher percentage steps means faster compile time, but we might overstep the required threshold too far with something higher than 5%
$step: 2%;
// Run through some cases where we want to darken, or use a negative step value
@if $fg-lod == 'light' and $bg-lod == 'light' {
// Both are light colors, darken the fg (make the step value negative)
$step: - $step;
} @else if $fg-lod == 'dark' and $bg-lod == 'light' {
// bg is light, fg is dark but does not pass, darken more
$step: - $step;
}
// Keeping the rest of the logic here, but our default values do not change, so this logic is not needed
//@else if $fg-lod == 'light' and $bg-lod == 'dark' {
// // bg is dark, fg is light but does not pass, lighten further
// $step: $step;
//} @else if $fg-lod == 'dark' and $bg-lod == 'dark' {
// // Both are dark, so lighten the fg
// $step: $step;
//}
// The magic happens here
// Loop through with a @while statement until the color combination passes our required ratio. Scale the color by our step value until the expression is false
// This might loop 100 times or more depending on the colors
@while color-contrast($fg, $bg) < $ratio {
// Moving the lightness is most effective, but also moving the saturation by a little bit is nice and helps maintain the "power" of the color
$fg: scale-color($fg, $lightness: $step, $saturation: $step/2);
}
@return $fg;
}
}
The full implementation
The complete set of functions — helpers, the 256-line lookup table, and extensive comments — is available in the GitHub repo. You can also open it in CodePen to edit the color values at the top of the file and see the adjustments as they happen.
Is it production-ready? We'd say maybe. It's been through several iterations and we're confident in the core logic, but it needs to be road-tested on real projects. Build time performance and edge cases with borderline color values are the open questions. If you try it, we'd welcome feedback via the repo's issue tracker.



