Color Contrast Ratio (WCAG)
What you'll practice:
Turning a published accessibility formula into small, testable functions Parsing and validating hex colors, including the three-digit shorthand Keeping full precision when a threshold decides pass or fail
Expected behavior
Your contrastRatio() implementation should:
Accept #rgb and #rrggbb strings in any letter case Convert each channel to linear light with the 0.04045 threshold before weighting it Return (lighter + 0.05) / (darker + 0.05), so the order of the arguments does not matter Return 21 for black on white and 1 for any color against itself Return the unrounded ratio so a value just under 4.5 never rounds up to a pass Throw a TypeError when either argument is not a valid hex color string
Arguments
foreground: string —A hex color, #rgb or #rrggbb. background: string —A hex color, #rgb or #rrggbb.
Returns
number — Solution idea
Interview answer: split the formula into three small functions. parseHex validates the string and returns three channel values from 0 to 255. toLinear undoes the sRGB gamma curve: divide by 255, then use c / 12.92 at or below 0.04045 and ((c + 0.055) / 1.055) ** 2.4 above it. relativeLuminance weights the linear channels 0.2126, 0.7152 and 0.0722, because the eye is most sensitive to green. The ratio is (lighter + 0.05) / (darker + 0.05) and runs from 1 to 21. Compare that raw number with the WCAG thresholds: 4.5 for normal text, 3 for large text and 7 for AAA. Rounding first can turn a failing 4.48 into a passing 4.5.
contrastRatio('#000000', '#ffffff'); // => 21
contrastRatio('#767676', '#ffffff'); // => about 4.54, passes AA for normal text
contrastRatio('#777777', '#ffffff'); // => about 4.48, fails AA (never round up)
contrastRatio('#fff', '#2563eb'); // => about 5.17
contrastRatio('#2563eb', '#fff'); // => the same value
contrastRatio('red', '#fff'); // throws TypeErrorPublic interview reports
These public, self-reported interview accounts informed this practice version. The exercise is modelled on reported rounds, not copied from them, and the reports are not verified official questions or endorsements.
- Taro: Tesla front end developer interview, Los Angeles, 2022
The virtual onsite in this self-reported loop lists "Calculate the contrast between certain colors" among its tasks. This drill is a practice version of that task; the hex input format and the error rule are ours.
Solution
Overview
Interview answer: split the formula into three small functions. parseHex validates the string and returns three channel values from 0 to 255. toLinear undoes the sRGB gamma curve: divide by 255, then use c / 12.92 at or below 0.04045 and ((c + 0.055) / 1.055) ** 2.4 above it. relativeLuminance weights the linear channels 0.2126, 0.7152 and 0.0722, because the eye is most sensitive to green. The ratio is (lighter + 0.05) / (darker + 0.05) and runs from 1 to 21. Compare that raw number with the WCAG thresholds: 4.5 for normal text, 3 for large text and 7 for AAA. Rounding first can turn a failing 4.48 into a passing 4.5.
Mental model: decode the gamma, weight the channels, then compare the lighter color with the darker one.
Approach 1: Small functions for parse, linearize and luminance
contrastRatio computes both luminances, puts the lighter one in the numerator with Math.max and Math.min, and returns the unrounded ratio. relativeLuminance maps the parsed channels through toLinear and applies the weights. parseHex checks the shape with one regular expression, doubles each shorthand digit, and parses the three pairs. Every helper is easy to test on its own.
export default function contrastRatio(foreground, background) {
const l1 = relativeLuminance(foreground);
const l2 = relativeLuminance(background);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
function relativeLuminance(hex) {
const [r, g, b] = parseHex(hex).map(toLinear);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function toLinear(channel) {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function parseHex(hex) {
if (typeof hex !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) {
throw new TypeError(`Invalid hex color: ${String(hex)}`);
}
const digits = hex.length === 4 ? [...hex.slice(1)].map((d) => d + d).join('') : hex.slice(1);
return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16));
}
export default function contrastRatio(foreground: string, background: string): number {
const l1 = relativeLuminance(foreground);
const l2 = relativeLuminance(background);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
function relativeLuminance(hex: string): number {
const [r, g, b] = parseHex(hex).map(toLinear);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function toLinear(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function parseHex(hex: string): [number, number, number] {
if (typeof hex !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) {
throw new TypeError(`Invalid hex color: ${String(hex)}`);
}
const digits = hex.length === 4 ? [...hex.slice(1)].map((d) => d + d).join('') : hex.slice(1);
return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)) as [number, number, number];
}
Approach 2: Lookup table for the channel curve
When a design-token linter compares hundreds of pairs, the channel curve can be computed once for the 256 possible values. Parse the six digits as one integer, read red, green and blue with bit shifts, and look each one up in the table. The math and the results are identical to approach 1; only the cost per call drops.
const LINEAR = Array.from({ length: 256 }, (_, value) => {
const c = value / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
export default function contrastRatio(foreground, background) {
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
return (lighter + 0.05) / (darker + 0.05);
}
function luminance(hex) {
if (typeof hex !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) {
throw new TypeError(`Invalid hex color: ${String(hex)}`);
}
const full = hex.length === 4 ? hex.replace(/[0-9a-f]/gi, (d) => d + d) : hex;
const value = parseInt(full.slice(1), 16);
return 0.2126 * LINEAR[(value >> 16) & 255] + 0.7152 * LINEAR[(value >> 8) & 255] + 0.0722 * LINEAR[value & 255];
}
const LINEAR: number[] = Array.from({ length: 256 }, (_, value) => {
const c = value / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
export default function contrastRatio(foreground: string, background: string): number {
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
return (lighter + 0.05) / (darker + 0.05);
}
function luminance(hex: string): number {
if (typeof hex !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) {
throw new TypeError(`Invalid hex color: ${String(hex)}`);
}
const full = hex.length === 4 ? hex.replace(/[0-9a-f]/gi, (d) => d + d) : hex;
const value = parseInt(full.slice(1), 16);
return 0.2126 * LINEAR[(value >> 16) & 255] + 0.7152 * LINEAR[(value >> 8) & 255] + 0.0722 * LINEAR[value & 255];
}
Notes & Pitfalls
- Weighting the 0 to 255 values directly skips the gamma curve and gives wrong ratios for mid tones.
- Always dividing the first argument by the second returns values below 1 when the order flips.
- Rounding before comparing with 4.5 lets #777777 on white pass, although its ratio is about 4.48.
- Expanding #abc as #abcabc instead of #aabbcc.
- Identical colors → 1.
- Black and white in either order → 21.
- Shorthand or uppercase hex → the same result as the long lowercase form.
- Color names, a missing #, or 5 or 7 digits → TypeError.
- Translating a published formula into named helper functions.
- Validating with a regular expression before parsing.
- Lookup tables for repeated per-channel math.
Common mistakes on this challenge
Linearize, weight, then divide lighter by darker
Contrast is computed on linear light, not on the stored 0 to 255 values, and the lighter luminance always goes on top, so the ratio runs from 1 to 21.
- Divide each channel by 255, then use `c / 12.92` up to 0.04045 and `((c + 0.055) / 1.055) ** 2.4` above it.
- Weight the linear channels with 0.2126, 0.7152 and 0.0722 to get the relative luminance.
- Return `(lighter + 0.05) / (darker + 0.05)` without rounding it.
Common interview follow-ups:
- How would you support rgb() and hsl() strings, or 8-digit hex with alpha?
- How would you blend a semi-transparent foreground over its background before measuring?
- Which thresholds apply to large text and to UI components, and how would you report AA and AAA together?
- How does the APCA method in the WCAG 3 drafts differ from this ratio?
Resources
Similar questions
Guides
Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.
Built in public
The code behind FrontendAtlas lives on GitHub. A star helps other frontend engineers find it.