WCAG contrast ratio function
Calculate the WCAG 2 contrast ratio between two hex colours, grade it AA or AAA and find the nearest passing shade.
React / Next.jsUtilitieswcagcontrastaccessibilitycolor
Code
export type RGB = [number, number, number];
export function parseHex(hex: string): RGB {
let h = hex.trim().replace(/^#/, '');
if (h.length === 3) h = [...h].map((c) => c + c).join('');
if (!/^[0-9a-f]{6}$/i.test(h)) throw new Error(`Invalid colour "${hex}"`);
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as RGB;
}
const toHex = (rgb: RGB) => '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
// sRGB channel -> linear light, then weighted by how the eye sees each primary.
export function luminance([r, g, b]: RGB): number {
const lin = (v: number) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
export function contrast(fg: string, bg: string): number {
const a = luminance(parseHex(fg));
const b = luminance(parseHex(bg));
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
export function grade(ratio: number) {
return { aa: ratio >= 4.5, aaLarge: ratio >= 3, aaa: ratio >= 7, aaaLarge: ratio >= 4.5 };
}
// Mix the foreground toward black or white until the target ratio is met.
export function fixForeground(fg: string, bg: string, target = 4.5): string {
if (contrast(fg, bg) >= target) return toHex(parseHex(fg));
const base = parseHex(fg);
const towardBlack = luminance(parseHex(bg)) > 0.4; // dark text on light backgrounds
const end = towardBlack ? 0 : 255;
for (let t = 0.04; t <= 1.0001; t += 0.04) {
const mixed = base.map((v) => v + (end - v) * t) as RGB;
if (contrast(toHex(mixed), bg) >= target) return toHex(mixed);
}
return towardBlack ? '#000000' : '#ffffff';
}
How it works
What it solves
Accessible colour choices need a number, not a guess. WCAG 2 defines a contrast ratio between text and background, with minimums of 4.5 to 1 for body text and 3 to 1 for large text at level AA. This module implements the formula so you can validate design tokens in tests or show live feedback in a theme editor.
How it works
parseHex accepts three or six digit hex strings and returns RGB channels. Each channel is converted from sRGB to linear light with the piecewise gamma function: values at or below 0.03928 are divided by 12.92, others use ((c + 0.055) / 1.055) ^ 2.4. The relative luminance is the weighted sum 0.2126 R + 0.7152 G + 0.0722 B, reflecting how strongly the eye perceives each primary.
contrast takes the lighter and darker luminance values and returns (L1 + 0.05) / (L2 + 0.05). grade maps the ratio to booleans for AA and AAA at normal and large sizes, so a UI can show pass or fail badges.
fixForeground helps designers: if the pair fails, it repeatedly mixes the foreground toward black or white, whichever direction increases contrast, in 4 percent steps until the target ratio is reached. The result is the closest passing colour along that line.
Adapting it
- Run
contrastover every foreground and background pair in a design system test. - Accept RGB triples instead of hex for canvas work.
- Use APCA for newer guidelines, which needs a different formula.
Pitfalls
- The check ignores font weight and anti-aliasing; treat it as a minimum.
- Transparent colours must be blended onto their background first.
How to use it
- Save as
lib/contrast.ts(no packages). contrast('#767676', '#ffffff')returns about 4.54.grade(ratio)returns{ aa, aaLarge, aaa, aaaLarge }booleans.fixForeground('#999999', '#ffffff')returns the nearest hex that reaches 4.5.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.