A practical WCAG contrast ratio reference covering all five thresholds, common false passes, and the edge cases designers often miss.

Everyone remembers 4.5:1. Far fewer people remember that WCAG contrast has five thresholds, that 18px regular text does not get the “large text” exception, and that WCAG 2.1 added a separate 3:1 rule for UI boundaries and meaningful graphics. If you need to push back on a handoff, the contrast checker gives you the math, but you still need to know which rule applies.
Most contrast arguments in design reviews are not about math. They’re about applying the wrong threshold, leaning on the wrong exception, or treating “close enough” as a pass.
That’s how you end up with all of these being waved through:
If you build from specs, this matters because contrast bugs are expensive late in the cycle. They affect design tokens, component states, and sometimes entire color systems. A bad handoff can multiply into dozens of failing components.
The practical move is simple: know all five thresholds, know the narrow exceptions, and check the actual rendered pair. That keeps the conversation objective instead of subjective.
Before getting into the common mistakes, here’s the reference table you can keep open during review. Use the contrast checker to verify any of these against the exact foreground and background colors in your UI.
| Criterion | WCAG Level | Applies to | Minimum ratio |
|---|---|---|---|
| SC 1.4.3 | AA | Normal text and images of text | 4.5:1 |
| SC 1.4.3 | AA | Large text (18pt/24px+ or 14pt/18.67px+ bold) | 3:1 |
| SC 1.4.6 | AAA | Normal text | 7:1 |
| SC 1.4.6 | AAA | Large text | 4.5:1 |
| SC 1.4.11 | AA | UI components and graphical objects | 3:1 |
A few important consequences fall straight out of that table:
If you want a quick sanity check in code review, this tiny reference object makes the thresholds explicit:
const wcagThresholds = {
aa: {
normalText: 4.5,
largeText: 3,
nonTextUiAndGraphics: 3
},
aaa: {
normalText: 7,
largeText: 4.5
}
};
console.log(wcagThresholds.aa.normalText); // 4.5
console.log(wcagThresholds.aaa.normalText); // 7That may look trivial, but writing the numbers down prevents a very common failure mode: developers memorizing one ratio and applying it everywhere.
The rule is the rule. There is no rounding up.
These two examples are the one to keep handy when someone says a near miss is “basically fine”:
These colors are visually indistinguishable. One passes. One doesn’t.
That’s exactly why contrast should be checked numerically instead of by eye. In design review, “it looks the same” is irrelevant if the ratio misses the threshold.
Here’s a runnable example showing the values as data you can assert against:
const examples = [
{
foreground: "#777777",
background: "#ffffff",
ratio: 4.47,
result: "FAILS WCAG AA"
},
{
foreground: "#767676",
background: "#ffffff",
ratio: 4.54,
result: "PASSES WCAG AA"
}
];
for (const example of examples) {
console.log(
`${example.foreground} on ${example.background} = ${example.ratio}:1 — ${example.result}`
);
}If your team keeps colors in tokens, make the threshold part of the acceptance criteria instead of relying on eyeballing:
:root {
--text-muted-fail: #777777;
--text-muted-pass: #767676;
--surface: #ffffff;
}
.fail-example {
color: var(--text-muted-fail);
background: var(--surface);
}
.pass-example {
color: var(--text-muted-pass);
background: var(--surface);
}A token that lands at 4.47:1 is not “nearly compliant.” It is non-compliant.
This is where a lot of handoffs go wrong.
WCAG defines large text using point sizes, which convert to these practical CSS equivalents:
That means the common argument “it’s 18px, so 3:1 is fine” is just wrong for regular-weight text.
A typical handoff mistake looks like this:
“The section heading is 20px and 3.5:1, so it passes as large text.”
It does not. 20px regular weight does NOT qualify as large text. It still needs 4.5:1 under SC 1.4.3 AA.
Here’s the exact logic written down:
function qualifiesAsLargeText({ fontSizePx, fontWeight }) {
const isBold = fontWeight >= 700;
if (fontSizePx >= 24) return true;
if (isBold && fontSizePx >= 18.67) return true;
return false;
}
const samples = [
{ fontSizePx: 24, fontWeight: 400, expected: true },
{ fontSizePx: 18.67, fontWeight: 700, expected: true },
{ fontSizePx: 20, fontWeight: 400, expected: false },
{ fontSizePx: 18, fontWeight: 400, expected: false }
];
for (const sample of samples) {
console.log(
`${sample.fontSizePx}px @ ${sample.fontWeight} => ${qualifiesAsLargeText(sample)}`
);
}And here’s the practical review rule:
If your team keeps design specs in points but implements in pixels, convert them explicitly. If you need to normalize a handoff color format before checking it, the color converter is useful for moving between HEX, RGB, and HSL without introducing mistakes during review.
This is the big one.
SC 1.4.11 is a Level AA requirement added in June 2018, and it covers UI components and graphical objects. That includes things like:
The key point: the text inside a component can pass while the component itself still fails.
Use the required example because it shows the problem clearly:
The button boundary must have at least 3:1 contrast against the adjacent background to pass 1.4.11. No border + no distinct background color = fail, even if button text passes.
That means this UI fails if the button blends into the page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Non-text contrast failure</title>
<style>
body {
margin: 0;
padding: 2rem;
background: #f0f0f0;
font-family: system-ui, sans-serif;
}
.button-fail {
background: #ffffff;
color: #111111;
border: none;
padding: 0.875rem 1.25rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
}
</style>
</head>
<body>
<button class="button-fail">Save changes</button>
</body>
</html>The text is dark enough. The button text may pass. The problem is the button boundary against the page background.
A compliant fix gives the component a visible edge or a sufficiently distinct fill:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Non-text contrast pass</title>
<style>
body {
margin: 0;
padding: 2rem;
background: #f0f0f0;
font-family: system-ui, sans-serif;
}
.button-pass {
background: #ffffff;
color: #111111;
border: 1px solid #767676;
padding: 0.875rem 1.25rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
}
.button-pass:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 3px;
}
</style>
</head>
<body>
<button class="button-pass">Save changes</button>
</body>
</html>When reviewing these, don’t just check text-on-fill. Check adjacent colors: border vs page, icon vs background, focus ring vs what surrounds it. The contrast checker is the fastest way to verify those pairs.
Consider a design handoff for a settings panel with these specs:
#f0f0f0#ffffff#11111120px, regular weight, color #6f6f6f#77777716pxAt first glance, the file might get an “accessible” label because the button text looks dark and the heading looks fairly prominent. Here’s how you should actually audit it.
That classification step is where most false passes happen.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Contrast audit example</title>
<style>
:root {
--page-bg: #f0f0f0;
--button-bg: #ffffff;
--button-text: #111111;
--heading: #6f6f6f;
--body-muted: #777777;
--brand-link: #008c95;
}
body {
margin: 0;
padding: 2rem;
background: var(--page-bg);
color: #111111;
font: 16px/1.5 system-ui, sans-serif;
}
.panel {
max-width: 42rem;
padding: 1.5rem;
background: #ffffff;
border-radius: 12px;
}
h2 {
margin-top: 0;
font-size: 20px;
font-weight: 400;
color: var(--heading);
}
.muted {
color: var(--body-muted);
}
a {
color: var(--brand-link);
}
.button {
background: var(--button-bg);
color: var(--button-text);
border: none;
padding: 0.875rem 1.25rem;
border-radius: 8px;
font: inherit;
}
</style>
</head>
<body>
<section class="panel">
<h2>Notification settings</h2>
<p class="muted">Choose which updates should trigger an email.</p>
<p><a href="/">Manage delivery preferences</a></p>
<button class="button">Save changes</button>
</section>
</body>
</html>This handoff has at least three red flags:
#777777 used for body text is a known fail on white at 4.47:120px regular heading does not qualify as large text, so it needs 4.5:1You can even formalize the review in JavaScript:
const reviewItems = [
{
item: "Secondary body text",
foreground: "#777777",
background: "#ffffff",
appliesTo: "Normal text",
requiredRatio: 4.5,
actualRatio: 4.47
},
{
item: "20px regular heading",
foreground: "#6f6f6f",
background: "#ffffff",
appliesTo: "Normal text",
requiredRatio: 4.5,
actualRatio: 5.02
},
{
item: "White button boundary against page",
foreground: "#ffffff",
background: "#f0f0f0",
appliesTo: "UI component boundary",
requiredRatio: 3,
actualRatio: 1.1
}
];
for (const item of reviewItems) {
const passes = item.actualRatio >= item.requiredRatio;
console.log(
`${item.item}: ${item.actualRatio}:1 vs required ${item.requiredRatio}:1 => ${passes ? "PASS" : "FAIL"}`
);
}This is where contrast work stays efficient. You usually don’t need a full palette redesign. You need the nearest compliant shade and a visible boundary.
For example:
#777777 body text with #767676 or darkerWhen a token is close but failing, the shade and tint generator is useful for finding the nearest darker or lighter compliant variant without guessing.
This point should end a lot of unnecessary debate.
WCAG says:
"Corporate visual guidelines beyond logo and logotype are not included in the exception." — WCAG Understanding SC 1.4.3
State the implication plainly:
No hedging. No “but marketing prefers it.” If a brand color is used as 16px button text, body copy, or link text, it must meet the applicable text contrast threshold like any other color.
A lot of “this isn’t accessible” comments are really “this doesn’t meet AAA,” which is a different claim.
Here’s the side-by-side version worth keeping in specs:
| Applies to | AA | AAA |
|---|---|---|
| Normal text | 4.5:1 | 7:1 |
| Large text | 3:1 | 4.5:1 |
That leads to a very practical rule for reviews:
Most legal and policy frameworks teams talk about in practice—ADA, EN 301 549, AODA—typically target Level AA, not AAA. So if a designer says “5:1 isn’t accessible,” they are confusing AA with AAA’s 7:1 requirement.
Three scenarios cause more confusion than they should:
Placeholder text — must meet contrast if CSS-styled, not exempt
If you intentionally style placeholder text, treat it like text that users need to read. Low-contrast placeholder gray is still a failure if users rely on it.
Text over images/gradients — contrast must hold across the full range of background colors encountered
You don’t get to sample the nicest part of the hero image. If the text crosses light and dark areas, it has to remain compliant everywhere it appears. Sometimes the fix is a solid overlay, text shadow, or a constrained text container rather than changing the text color alone.
Opacity/alpha — measure contrast against the actual rendered background color, not the specified one
rgba(0, 0, 0, 0.6) over white and over a tinted surface are different effective colors. Check the composited result, not the declared token in isolation.
The common pattern in all three: evaluate the rendered UI, not the design intention. If a color is close but fails, adjust to the nearest compliant shade instead of jumping to a random darker value. That’s exactly where the shade and tint generator helps.
The rules are specific, the exceptions are narrow, and the math settles arguments faster than opinions do. Keep the contrast checker open during handoff review, use it to verify the exact pair you’re shipping, and when a color misses by a hair, use the shade and tint generator to find a compliant replacement without wrecking the visual system.