Fixing contrast-ratio in DESIGN.md

The contrast-ratio rule fires with severity: warning when a component defines both backgroundColor and textColor and the computed WCAG contrast ratio between the two resolved colors is strictly below 4.5:1. Passing pairs produce no finding. The rule is skipped for components missing either property.

What WCAG AA requires#

WCAG 2.1 Success Criterion 1.4.3 requires a contrast ratio of at least 4.5:1 between normal text and its background. The ratio is calculated from the relative luminance of each color (per the WCAG 2.1 formula), not from a perceptual approximation. A ratio of exactly 4.5:1 passes; a ratio of 4.49:1 does not.

The linter applies this check to every component that has both backgroundColor and textColor defined as resolvable color values.

What triggers it#

The rule runs through all components and for each:

  1. Reads the backgroundColor and textColor property values.
  2. Skips the component if either property is absent.
  3. Skips the component if either value cannot be resolved to a concrete color (for example, a dimension token or an unresolved reference).
  4. Computes the WCAG contrast ratio between the two resolved hex colors.
  5. Emits a warning if the ratio is strictly below 4.5 (the constant WCAG_AA_MINIMUM in the linter source).

Passing pairs produce no output from this rule. Only failing pairs appear in the findings list.

Example that trips the rule:

---
name: Soft UI
colors:
  surface: "#F7F5F2"
  muted: "#818A91"
components:
  caption-label:
    backgroundColor: "{colors.surface}"
    textColor: "{colors.muted}"
---

#818A91 (muted gray) on #F7F5F2 (off-white) produces a contrast ratio of 3.23:1, well below 4.5:1. The linter emits:

{
  "severity": "warning",
  "path": "components.caption-label",
  "message": "textColor (#818A91) on backgroundColor (#F7F5F2) has contrast ratio 3.23:1, below WCAG AA minimum of 4.5:1."
}

The message format is:

textColor (<hex>) on backgroundColor (<hex>) has contrast ratio <N.NN>:1, below WCAG AA minimum of 4.5:1.

The ratio is formatted to two decimal places. Note: the CLI uses an em dash in some other messages (see the unknown-key rule), but the contrast-ratio message uses plain colons and does not contain an em dash.

How to fix#

There are three standard approaches.

Option 1: Darken the text color#

Increase the luminance gap by using a darker value for textColor. Near-black values like #111111 or #1A1C1E pass against nearly all light backgrounds:

colors:
  surface: "#F7F5F2"
  muted: "#818A91"
  body: "#1A1C1E"
components:
  caption-label:
    backgroundColor: "{colors.surface}"
    textColor: "{colors.body}"

Option 2: Lighten the background and darken the text token slightly#

Switching to pure white and using a modestly darker muted shade can push a borderline pair into passing territory. For example, #6C7278 on #FFFFFF clears WCAG AA:

colors:
  white: "#FFFFFF"
  muted: "#6C7278"
components:
  caption-label:
    backgroundColor: "{colors.white}"
    textColor: "{colors.muted}"

#6C7278 on #FFFFFF has a contrast ratio of approximately 4.87:1, which passes.

Option 3: Increase contrast between both values#

If both colors are in the mid-range, shift them apart. Darken the text or lighten the background until the ratio reaches 4.5:1.

Using the tool to preview pairs#

Paste your DESIGN.md into the tool and navigate to the diagnostics panel. Each contrast-ratio warning appears as an inline finding with the affected component path, the two hex values, and the computed ratio. Adjust the color values in the editor and the panel updates in real time.

When the rule does not fire#

The rule is intentionally narrow. It skips:

  • Components with only backgroundColor or only textColor (not both).
  • Property values that resolve to a dimension (such as padding, rounded) or typography token.
  • References that did not resolve at all (those produce a broken-ref error instead).
  • Components that pass 4.5:1 exactly or above.

This means the rule only reports confirmed failures. A clean lint output on contrast-ratio means every checked component pair meets WCAG AA, but it does not mean every component has been checked.

Checking component variants#

If you define interaction-state variants (for example, button-primary-hover or button-primary-disabled), each variant is treated as an independent component. A variant with low-contrast disabled text (often intentional in some design systems) will still trigger the warning. If disabled states are intentionally below AA, they will appear as warnings. The rule does not have a severity-override mechanism; accept the advisory or adjust the palette.

Checking ratios manually#

To compute the ratio for a pair before adding it to your file, use the WCAG relative luminance formula or an online calculator. The linter uses the standard WCAG 2.1 algorithm: linearize each RGB channel (divide by 255, apply a gamma correction), compute luminance as a weighted sum (0.2126 R + 0.7152 G + 0.0722 B), then apply (L1 + 0.05) / (L2 + 0.05) where L1 is the lighter luminance.


Try it in the tool

Was this page helpful?