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:
-
packages/cli/src/linter/model/handler.ts– Contains thecontrastRatiofunction and color resolution logic that computes luminance values for resolved tokens. -
packages/cli/src/linter/rules/contrast-ratio.ts– Implements thecontrastCheckrule that traverses components and emits accessibility warnings. -
packages/cli/src/commands/lint.ts– CLI entry point that executes the linter and formats output for terminal display. -
packages/cli/src/linter/index.ts– Rule registry wherecontrastCheckis registered alongside other lint rules.
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
contrastRatiofunction inhandler.tsoperates onResolvedColorobjects containing pre-computed luminance values. - The
contrastCheckrule enforces a 4.5:1 minimum ratio for WCAG AA compliance across all components withbackgroundColorandtextColorproperties. - Violations generate detailed warnings showing exact hex values and calculated ratios.
- The modular architecture allows programmatic reuse of
contrastRatiofor 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →