DEV Community

Joe Lin for BeGoodTool.com

Posted on

Color Contrast Matrix Checker: Turning WCAG Pair Checks into a Readable Grid

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)