Manual contrast checks catch problems the day someone remembers to run them. A linter catches problems every single time someone opens a pull request, which is the only cadence that actually prevents regressions from reaching production. Here's how to wire one into a typical CI pipeline.
We built our first version of this after watching the same failure happen three times on one project: a color would get manually verified, ship correctly, then quietly regress a few weeks later when an unrelated component change reused the token in a new context nobody thought to re-check. A linter turns that from "hopefully someone notices" into "the build fails until it's fixed," which is a much better guarantee for something that affects real users.
This is one of those pieces of infrastructure that pays for itself quietly. Nobody notices a linter that's working, they only notice the regression it would have caught if it wasn't there, which makes it an easy line item to skip when a project is scoped under time pressure. It's worth pushing for anyway.
Step 1: Centralize Your Color Values First
A linter can only check pairs it knows about. If colors are scattered as raw hex values across dozens of component files, there's nothing consistent to validate. Before adding any tooling, consolidate colors into a single source, whether that's a JSON token file, CSS custom properties, or a design-tokens package. This step alone often surfaces duplicate near-identical colors that should be merged.
Consolidating first also gives you a natural inventory of exactly how many colors you're actually maintaining, which is often a smaller number than it feels like day to day, but occasionally a much larger and messier one than anyone expected. Either finding is useful going into the next step.
Step 2: Define Your Pairs Explicitly
A contrast linter needs to know which foreground goes with which background, since a raw list of colors has no concept of pairing. Structure your token file so each text or icon color declares the surface it's meant to sit on, for example text-primary paired explicitly with surface-default and separately with surface-raised. This mirrors how the color actually gets used in components, which is the only way an automated check produces meaningful results instead of false positives.
Give yourself a rough timeline for this, since the temptation is to keep polishing the consolidated token file indefinitely instead of moving on to the linter itself. A first pass that's ninety percent complete and actually shipped catches more real regressions than a perfect token file that never makes it into CI.
Step 3: Pick a Contrast Calculation Library
You don't need to implement the WCAG contrast math yourself. Reliable open-source libraries exist for calculating relative luminance and contrast ratios from hex or RGB values in most languages, and it's worth cross-checking your library's output against WebAIM's calculator once during setup to make sure the numbers agree. Wire whichever library fits your stack into a small script that reads your token file, computes every declared pair, and compares each result against the required threshold, 4.5:1 for normal text, 3:1 for large text and UI components.
This is also the point where teams discover pairs they never intended to exist, a hover state built on a background nobody planned for, a chart color reused as body text in one obscure view. Writing the pairs out explicitly surfaces these accidental combinations before the linter even runs, just from the act of enumerating them.
Step 4: Fail the Build on Violations
The script should exit with a non-zero status code if any pair fails its threshold, and print the offending pair with its actual ratio so a developer can fix it without hunting. Wire that script into your CI configuration as a required check on pull requests, the same way you'd treat a failing unit test. This is the step that actually prevents regressions, since a warning that doesn't block a merge gets ignored under deadline pressure.
Step 5: Add an Allowlist for Legitimate Exceptions
Some pairs genuinely don't need to meet the standard threshold, decorative elements, disabled states, or large-format marketing type that falls under a different rule. Build a small, explicitly documented allowlist into the linter config rather than letting developers silently bypass the check. Every entry in that list should have a one-line reason attached, so future reviewers understand why it's there instead of assuming it's an oversight.
Step 6: Extend the Check to Dark Mode Separately
If your project supports both light and dark themes, run the linter against both token sets independently. Dark mode pairs are not a mathematical transform of light mode pairs, a distinction covered well in MDN's guide to prefers-color-scheme, and treating them as equivalent in your linter config will let dark mode regressions slip through even while light mode stays clean.
Step 7: Report Results Somewhere Visible
A check that only shows up as a red X in a PR gets fixed reactively. Consider posting a short summary comment on the pull request listing exactly which pairs failed and by how much, so the fix is obvious without a developer having to dig through CI logs. Over time this visibility also helps a team notice patterns, like a specific component repeatedly introducing low-contrast text.
Step 8: Keep the Linter Fast
A contrast check that adds two minutes to every CI run gets disabled the first time someone's in a hurry. Since the actual math is cheap, computing luminance and ratio for a token pair is a handful of arithmetic operations, the slow part is usually unnecessary overhead: re-parsing the entire token file on every run, or checking pairs that haven't changed since the last commit. Cache the parsed token file between runs and, if your pipeline supports it, only re-check pairs touched by the current diff. Keeping the check under a few seconds is what keeps it from becoming the thing everyone quietly disables.
Step 9: Extend It to Catch New Colors, Not Just Bad Pairs
A linter that only checks declared pairs misses the most common regression: someone adding a brand new raw hex value directly in a component instead of referencing an existing token. Add a secondary rule that flags any color literal outside the approved token file, even if that literal happens to pass its contrast check. This is what actually prevents the token system from eroding over time, since a color that bypasses the token file today is a color nobody will remember to re-verify after the next redesign.
Why This Is Worth the Setup Time
A contrast linter turns accessibility from a periodic audit into a standing guarantee. Once it's running, new features can't silently regress the color system that took real effort to get right in the first place. It also changes the tone of accessibility work on a team generally, moving it from an occasional scramble before a compliance deadline to a quiet, automated background check that just happens, the same way type checking or a formatter does.
Once it's running for a few months, most teams stop thinking about it entirely, which is exactly the point. The goal isn't a linter anyone has to remember to care about, it's one that quietly keeps a real problem from ever reaching production again. For the broader context on building that system, including how to structure the token pairs this linter checks, see 137Foundry's guide on building an accessible web color system.
Top comments (0)