Guide
How to fix color contrast without losing your brand
Low contrast is the most common accessibility failure on the web, and also one of the easiest to fix once you know the numbers. This guide explains the WCAG ratios, the traps that hide failing text, and how to pick a passing color that still looks like your brand instead of falling back to plain black.
The numbers you need to know
WCAG measures contrast as a ratio between the relative luminance of two colors. It goes from 1:1 (the same color) to 21:1 (black on white). The thresholds are simple:
| What | Level AA | Level AAA |
|---|---|---|
| Normal text | 4.5:1 | 7:1 |
| Large text (24px, or 18.66px bold) | 3:1 | 4.5:1 |
| Icons, field borders, focus indicators, chart lines | 3:1 | 3:1 |
Text contrast is criterion 1.4.3 Contrast (Minimum). The 3:1 rule for controls and graphics is 1.4.11 Non-text Contrast. Large text is defined in CSS pixels: 24px regular, or 18.66px (14pt) when the weight is 700 or more. A 20px semibold heading is not large text and needs the full 4.5:1.
Some quick reference points on a white background: #999999 is 2.84:1 and fails, #767676 is 4.54:1 and is the lightest pure gray that passes, and #595959 is 7:1 for AAA.
Who low contrast hurts
Contrast is not only about people who are blind or have very low vision. It affects people with cataracts, glaucoma or color vision deficiency, older adults (contrast sensitivity drops with age), people using cheap or dim screens, and anyone reading a phone outdoors in sunlight. Light gray body text that looks elegant on a calibrated monitor in a dark office becomes unreadable on a bus at noon.
That is also why "it looks fine to me" is not a test. Designers usually have good eyesight and good screens. The ratio is the only objective check.
Find every failing piece of text
Checking colors one pair at a time in a picker misses the cases that matter: inherited colors, semi transparent overlays, text on a gradient, text inside a card with its own background. a11ymedic walks up the tree from each text node, blends every background and opacity layer it passes, and measures the result. For gradients it measures every color stop and reports the worst one.
# AA contrast only
npx a11ymedic "src/**/*.html" --rules color-contrast
# AA and AAA
npx a11ymedic "src/**/*.html" --rules color-contrast,color-contrast-enhanced --level AAAEach finding states the measured ratio, the two colors, whether the text counts as large, and the required ratio. For example: "Contrast is 2.84:1 (#999999 on #ffffff). Normal text needs 4.5:1."
Text placed over a background image cannot be measured from code, because the pixels behind each letter are unknown. Those cases are listed as "needs a human check" instead of being guessed. Text inside disabled controls is skipped, because WCAG exempts it.
Pick a passing color that keeps your brand
The lazy fix is to make all text black. It works, but it throws away your design. The better fix is the closest color that passes. a11ymedic converts the color to OKLCH, a color space designed so that equal steps look equally different to the human eye. It keeps the hue and chroma, and moves only the lightness until the ratio is reached. The result looks like a slightly darker (or lighter) version of the same color, not a different color.
import { suggestColors, contrastRatio } from "a11ymedic";
contrastRatio("#4a90e2", "#ffffff"); // 3.29, fails for normal text
const s = suggestColors("#4a90e2", "#ffffff");
s.foreground; // "#3278c9", same blue, darker
s.ratio; // 4.5
s.changed; // "foreground"
s.backgroundOption; // or keep the text and darken the background
// Ask for AAA instead
suggestColors("#4a90e2", "#ffffff", { ratio: 7 });It also tries the opposite move, changing the background and keeping the text, and picks whichever is the smaller visual change. When no single color can reach the target (for example 7:1 on a mid tone background), it changes both. You can try this interactively in the contrast tool on the home page.
Fix it once, in your design tokens
If the same gray fails in forty places, do not patch forty elements. Change the variable they all use. That also prevents the problem from coming back in the next component.
/* Before: 2.84:1 on white, fails AA */
:root {
--text-muted: #999999;
--link: #4a90e2;
}
/* After: same hues, lightness adjusted to pass 4.5:1 */
:root {
--text-muted: #767676;
--link: #3278c9;
}Remember dark mode. A color that passes on white can fail on your dark surface, so check both themes. Hover and focus states count too: a link that darkens on hover is fine, a button that turns pale on hover is not.
Common traps
- Placeholder text. Browsers often render placeholders at a low opacity. If the placeholder carries instructions, it must pass 4.5:1. Better still, move instructions out of the placeholder (see the form labels guide).
- Opacity on a parent.
opacity: 0.6on a card fades every child, including text that was fine on its own. - White text on brand colors. White on a mid orange or a light green almost always fails. Darken the button background a little with the suggestion tool instead of changing the text.
- Thin fonts. A passing ratio with a 300 weight font at 14px is still hard to read. The ratio is a minimum, not a goal.
- Logos and decorative text. Logos are exempt, and so is text that is pure decoration. Text in a disabled control is exempt too.
The full set of rules is in the rules reference, and the WCAG cheat sheet lists every related criterion.
Frequently asked questions
What is the minimum color contrast ratio for WCAG AA?
4.5:1 for normal text and 3:1 for large text, which is 24px regular or 18.66px bold. User interface parts such as input borders and icons need 3:1.
How do I fix color contrast without changing my brand colors?
Keep the hue and adjust only the lightness until the ratio passes. a11ymedic does this in the OKLCH color space and suggests the closest passing shade for the text or for the background.
Does text over an image need to meet contrast requirements?
Yes. Measure the worst spot behind the text. Because the pixels are not in the code, tools report it as a manual check. A solid or semi opaque backing behind the text is the most reliable fix.
Is #767676 accessible on white?
Yes. #767676 on #ffffff is about 4.54:1, which passes AA for normal text. It is the lightest neutral gray that does.