Accessibility reviews get repetitive when a design system has four, six, or ten colors. A designer may check dark text on white, then forget the warning color on a tinted card or the inverse combination in a badge. I built the Color Contrast Matrix Checker to turn those pairwise questions into one table: every entered color becomes a row and a column, and every ordered foreground/background pair gets a WCAG result.
The primary reader is a frontend developer or design-system maintainer who needs to audit a palette before wiring it into components. The takeaway is both the math and the shape of the work: a contrast ratio is based on relative luminance, while the matrix makes the number of combinations visible. The tool is a concrete browser implementation of that calculation, not a promise that RGB alone describes every rendered design.
Normalize the palette before calculating
The interface starts with four colors and permits adding colors up to ten. Each row has a native color picker and a text field, but the calculation accepts only three- or six-digit hexadecimal values:
function isValidHex(hex) {
return /^#([0-9a-fA-F]{3}){1,2}$/.test(hex);
}
function normalizeHex(hex) {
const clean = hex.replace(/^#/, "");
if (clean.length === 3) {
return "#" + clean.split("").map((c) => c + c).join("");
}
return "#" + clean.toLowerCase();
}
On calculate, each row is synchronized from its picker when valid, then mapped to a normalized value. If any entry is invalid, the function stops and displays “At least 2 colors required” from the localization data. In practice the UI prevents removing below two colors, while the add button prevents more than ten. That upper bound also keeps the resulting table usable on a small screen.
The ratio is luminance math, not channel averaging
The source expands shorthand hex and converts each channel to a linear-light value:
function linearize(c) {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function relativeLuminance([r, g, b]) {
return 0.2126 * linearize(r) +
0.7152 * linearize(g) +
0.0722 * linearize(b);
}
function contrastRatio(hex1, hex2) {
const rgb1 = hexToRgb(hex1);
const rgb2 = hexToRgb(hex2);
if (!rgb1 || !rgb2) return null;
const l1 = relativeLuminance(rgb1);
const l2 = relativeLuminance(rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
The 0.05 terms prevent a zero-luminance color from making the ratio undefined. Taking the lighter and darker luminance makes the ratio symmetric: black on white and white on black have the same numeric result, even though the UI still labels one color as the row and the other as the column.
Thresholds depend on text size
The radio buttons select normal or large text. The checker then applies the source’s WCAG thresholds:
function getLevel(ratio, size) {
const aaThreshold = size === "large" ? 3.0 : 4.5;
const aaaThreshold = size === "large" ? 4.5 : 7.0;
if (ratio >= aaaThreshold) return "aaa";
if (ratio >= aaThreshold) return "aa";
return "fail";
}
The result loop deliberately computes both directions for every pair:
for (let i = 0; i < n; i++) {
const row = [];
for (let j = 0; j < n; j++) {
if (i === j) {
row.push({ same: true, bg: validColors[i], fg: "#888888", levelClass: "same" });
} else {
const ratio = contrastRatio(validColors[i], validColors[j]);
const level = getLevel(ratio, size);
row.push({
same: false, bg: validColors[i], fg: validColors[j],
ratio: ratio.toFixed(2) + ":1", level, levelClass: level,
});
}
}
result.push(row);
}
The diagonal is marked “Same” rather than pretending a color against itself is a useful text pairing. Summary counts exclude those cells, and totalPairs is n * (n - 1), so the total reflects ordered foreground/background combinations.
The ordered grid is useful even though the ratio itself is symmetric because the rendered preview is not abstract: each cell uses the row color as its background and the column color as its foreground. A design-system owner can therefore see which actual text/background assignment is being reviewed. The CSV keeps the same orientation with an FG \ BG header, making it possible to carry the review into a spreadsheet without losing which side of the pair was intended as text.
The matrix is also a debugging aid
Each cell shows the ratio and AA/AAA/fail label, with the cell background set to the row color and its text color set to the column color. The table is horizontally scrollable, and the export function writes the same values to color-contrast-matrix.csv. That makes the result useful in a review: a failing cell points to a specific pair instead of a vague “palette needs work” comment.
There are honest boundaries. The parser accepts RGB hex only; alpha, CSS gradients, images, and color-mix results are not modeled. The formula cannot know the font’s actual weight, anti-aliasing, surrounding colors, or a semi-transparent overlay. The “large” option is a simplified choice matching the tool’s labels, not a parser for every CSS typography rule. A ratio that passes in isolation can still be hard to read in a real component.
I turned this implementation into a small free tool: Color Contrast Matrix Checker.
Top comments (0)