How the WCAG Contrast Ratio Check Works in Design.md

The WCAG contrast ratio check follows a four-step pipeline: parsing design tokens into resolved colors with pre-computed luminance, calculating the WCAG 2.1 contrast ratio using (L1 + 0.05) / (L2 + 0.05), comparing results against the 4.5:1 AA minimum, and emitting detailed lint warnings for violations.

The google-labs-code/design.md repository implements accessibility validation as a core CLI feature. The contrast checker operates on resolved color tokens to ensure text remains readable against background colors, automatically handling token indirection and color aliases.

Understanding the WCAG Contrast Ratio Algorithm

The implementation follows the WCAG 2.1 contrast ratio formula exactly. Each color is converted to a linearized luminance value ranging from 0 (black) to 1 (white). The algorithm divides the lighter luminance plus 0.05 by the darker luminance plus 0.05 to produce a ratio where 1:1 represents no contrast and 21:1 represents maximum contrast (black on white).

Step-by-Step Implementation

Design System Parsing

The linter first parses the DESIGN.md file into a DesignSystemState. During this phase, the CLI resolves component properties like backgroundColor and textColor into ResolvedColor objects. These objects contain pre-computed luminance values, enabling efficient contrast calculations without repetitive color space conversions.

Contrast Calculation

The core mathematics live in packages/cli/src/linter/model/handler.ts. The contrastRatio function accepts two ResolvedColor objects and returns the WCAG-defined ratio:

// packages/cli/src/linter/model/handler.ts#L400-L406
export function contrastRatio(a: ResolvedColor, b: ResolvedColor): number {
  const L1 = Math.max(a.luminance, b.luminance);
  const L2 = Math.min(a.luminance, b.luminance);
  return (L1 + 0.05) / (L2 + 0.05);
}

This implementation strictly follows the WCAG specification by selecting the maximum luminance as the numerator and minimum as the denominator, ensuring the ratio always represents the relative brightness difference regardless of color order.

Rule Execution

The contrastCheck rule in packages/cli/src/linter/rules/contrast-ratio.ts orchestrates the validation. It iterates through every component in the design system, extracts resolved background and text colors, and invokes contrastRatio:

// packages/cli/src/linter/rules/contrast-ratio.ts#L19-L44
const WCAG_AA_MINIMUM = 4.5;

export function contrastCheck(state: DesignSystemState): RuleFinding[] {
  const findings: RuleFinding[] = [];
  for (const [compName, comp] of state.components) {
    const bgValue = comp.properties.get('backgroundColor');
    const textValue = comp.properties.get('textColor');
    if (!bgValue || !textValue) continue;

    const bgColor = resolveToColor(bgValue);
    const textColor = resolveToColor(textValue);
    if (!bgColor || !textColor) continue;

    const ratio = contrastRatio(bgColor, textColor);
    if (ratio < WCAG_AA_MINIMUM) {
      findings.push({
        path: `components.${compName}`,
        message: `textColor (${textColor.hex}) on backgroundColor (${bgColor.hex}) has contrast ratio ${ratio.toFixed(2)}:1, below WCAG AA minimum of ${WCAG_AA_MINIMUM}:1.`,
      });
    }
  }
  return findings;
}

The rule specifically checks against the WCAG AA minimum of 4.5:1 for normal text, creating a RuleFinding warning when colors fall below this threshold.

Reporting and CLI Output

When the linter detects insufficient contrast, it generates a structured warning containing the component path, specific hex values, and the computed ratio. The CLI's lint command formats these findings for display, showing exactly which color combination failed and by what margin.

Practical Usage Examples

Running the CLI Linter

Execute the contrast check against any DESIGN.md file using the CLI:


# Lint a DESIGN.md file and view contrast warnings

design-md lint path/to/DESIGN.md --format=text

Typical output identifies specific violations:

{
  "findings": [
    {
      "path": "components.Button",
      "message": "textColor (#777777) on backgroundColor (#FFFFFF) has contrast ratio 3.98:1, below WCAG AA minimum of 4.5:1."
    }
  ],
  "summary": { "warnings": 1, "errors": 0 }
}

Using the Contrast Function Programmatically

Access the calculation logic directly for custom tooling:

import { contrastRatio } from '@design-md/cli';
import { resolveColor } from '@design-md/cli/model';

const bg = resolveColor('#ffffff'); // luminance ≈ 1.0
const txt = resolveColor('#777777'); // luminance ≈ 0.42

const ratio = contrastRatio(bg, txt);
console.log(`Contrast ratio: ${ratio.toFixed(2)}:1`); // → 3.98:1

Creating Custom Rules

Extend the linter with custom contrast thresholds by reusing the core function:

import { contrastRatio } from '@design-md/cli/model/handler';
import type { DesignSystemState, ResolvedColor } from '@design-md/cli/model/spec';

export function strictContrastRule(state: DesignSystemState) {
  const findings = [];
  // Iterate components...
  const ratio = contrastRatio(colorA, colorB);
  if (ratio < 5) {
    findings.push({ 
      path: 'components.foo', 
      message: `Low contrast: ${ratio.toFixed(2)}:1` 
    });
  }
  return findings;
}

Key Source Files and Architecture

The contrast checking system spans four primary modules:

The architecture separates color resolution from rule logic, allowing the contrast calculation to work on fully resolved colors regardless of token indirection depth or alias complexity.

Summary

  • The WCAG contrast ratio check calculates relative luminance using (L1 + 0.05) / (L2 + 0.05) as defined in WCAG 2.1.
  • The contrastRatio function in handler.ts operates on ResolvedColor objects containing pre-computed luminance values.
  • The contrastCheck rule enforces a 4.5:1 minimum ratio for WCAG AA compliance across all components with backgroundColor and textColor properties.
  • Violations generate detailed warnings showing exact hex values and calculated ratios.
  • The modular architecture allows programmatic reuse of contrastRatio for custom linting rules or external tooling.

Frequently Asked Questions

What WCAG conformance level does the contrast checker enforce?

The contrastCheck rule enforces WCAG 2.1 Level AA standards, which require a minimum contrast ratio of 4.5:1 for normal text. This threshold is hardcoded as WCAG_AA_MINIMUM = 4.5 in packages/cli/src/linter/rules/contrast-ratio.ts.

How does the checker handle design tokens and color aliases?

The checker operates on resolved colors rather than raw token strings. During the parsing phase, the CLI resolves all token references, aliases, and nested color values into ResolvedColor objects containing computed luminance values. This ensures the contrast calculation evaluates the final color values regardless of token indirection depth.

Can I use the contrast calculation in my own scripts outside the CLI?

Yes. The contrastRatio function is exported from packages/cli/src/linter/model/handler.ts and can be imported programmatically. You must first resolve color values to ResolvedColor objects (which include luminance data) before passing them to the function.

Why does the contrast ratio formula add 0.05 to luminance values?

The 0.05 offset prevents division by zero when calculating ratios involving pure black (0 luminance) and accounts for ambient light effects in the WCAG 2.1 specification. This constant ensures the ratio between two identical colors equals 1:1 and maximum contrast (black vs white) equals 21:1.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →