RizTech Academy logo
RizTech Academy
AccessibilityLesson 4 of 620 min

Colour contrast and not relying on colour alone

Two separate problems get collapsed into "colour accessibility", and they need different fixes. Contrast is whether text is legible against its background. Colour reliance is whether you have used colour as the only way of telling something. A page can pass one and fail the other.

Contrast ratios

Contrast is expressed as a ratio between two relative luminances, from 1:1 (identical) to 21:1 (black on white).

The WCAG AA thresholds:

Content Minimum
Body text 4.5:1
Large text — 18.66px bold, or 24px 3:1
Interactive component boundaries, icons 3:1
Focus indicators 3:1
Decorative graphics, disabled controls no requirement

AAA asks for 7:1 body and 4.5:1 large, which is worth knowing and is not the usual target.

Two things people get wrong about the thresholds:

The "large text" exemption is smaller than it sounds. 24px, or 18.66px and bold. Your 20px subheading is body text as far as WCAG is concerned.

Placeholder text is not exempt. It is text, it needs 4.5:1, and the browser default placeholder grey usually fails. Which is another reason module 1 said a placeholder is not a label.

Measuring it

Do not guess. Three ways, in order of convenience:

Devtools. Inspect any text element, and the colour swatch in the Styles panel shows the contrast ratio with a tick or a cross. Click the swatch and it offers a colour picker with the AA and AAA thresholds drawn as lines — drag to the line and you have a passing colour that is as close as possible to the one you wanted.

The Accessibility pane. Select an element and read the contrast under "Accessibility"; it also flags low-contrast text in the Issues panel automatically.

In the console, when you want to check a pair without building anything:

const lum = (hex) => {
  const [r, g, b] = hex.match(/\w\w/g).map(h => parseInt(h, 16) / 255)
    .map(c => c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
  const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
  return ((x + 0.05) / (y + 0.05)).toFixed(2);
};

ratio("#1a4d2e", "#ffffff");   // 9.79
ratio("#767676", "#ffffff");   // 4.54  — just passes
ratio("#777777", "#ffffff");   // 4.48  — just fails

That last pair is worth remembering: #767676 is the lightest pure grey that passes 4.5:1 on white. One hex digit lighter fails. If you have ever wondered why designers keep landing on #767676, that is why.

Why the coefficients matter

0.2126 red, 0.7152 green, 0.0722 blue. Green carries most of perceived luminance and blue almost none, which produces results that are genuinely counter-intuitive until you have seen them:

Colour on white on black
Pure green #00ff00 1.37 15.30
Pure yellow #ffff00 1.07 19.56
Pure red #ff0000 4.00 5.25
Pure blue #0000ff 8.59 2.44

So green and yellow are light — they are nearly invisible on white and excellent on black. Blue is dark — it passes comfortably on white and fails on black. This is the opposite of what most people guess for blue, and it is why you cannot judge contrast by how vivid a colour looks.

And note the red: pure red on white is 4.00:1, which fails 4.5. That is the color: red error message in every beginner's stylesheet, failing the criterion it was written to satisfy — and another reason the colours lesson said to avoid the named colours.

Where contrast fails in practice

Five places, in roughly the order they appear:

Placeholder and "muted" text. A grey chosen because the design wanted it quiet. If it is worth showing, it is worth reading.

White text on a photograph. The photo's brightness varies across it, so text readable over the dark part fails over the light part. The module 2 fix is a translucent gradient layered over the image — and check it at both the darkest and the lightest part, not in the middle.

Brand colours. A brand green or orange chosen for a logo often fails as text. The answer is usually a darker variant for text and the original for large areas. Two tokens, not one.

Disabled controls. Technically exempt, and a disabled button nobody can read is still a usability problem. Aim for 3:1 anyway.

Dark mode. As module 4 said, the two ends are not symmetrical. A muted grey that passes on white frequently fails on near-black, because the same grey is closer in luminance to a dark background. Check both modes separately.

Do not rely on colour alone

The second problem, and the one automated tools cannot detect at all.

<!-- colour is the only signal -->
<p style="color: red">Enter a valid phone number</p>
<span class="dot dot--green"></span> In stock

For the 4 to 5 percent who cannot distinguish red from green, and for anybody in bright sunlight or on a poorly calibrated screen, those carry no information.

The rule: colour may reinforce, never carry. Add a second channel — text, an icon, a shape, a position, underlining.

<p class="error">
  <svg class="icon" aria-hidden="true" focusable="false">…</svg>
  Enter a 10-digit phone number, starting 6 to 9.
</p>

<p><span class="dot dot--green" aria-hidden="true"></span> In stock — 12 left</p>
<p><span class="dot dot--red" aria-hidden="true"></span> Out of stock</p>

The words carry the meaning; the colour and the dot make it faster to scan. Note aria-hidden="true" on the decorative dot and icon, so a screen reader does not announce them — the text already says it.

The most common colour-only failure on the web, and it is invisible to its authors.

a { color: #1a4d2e; text-decoration: none; }     /* colour is the only cue */

A link in a paragraph distinguished only by colour is undetectable to somebody who cannot see that colour — they cannot tell which words are clickable. WCAG requires either a non-colour distinction, or 3:1 contrast between the link and the surrounding text as well as 4.5:1 against the background, which is hard to achieve.

Keep links underlined in body text. It is unfashionable and it is correct. If the default underline is too heavy:

.prose a {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.2em;
}

That looks considerably better than the browser default and still works. Navigation and buttons are different — they are distinguished by position and shape, so they do not need underlines.

Form errors

<label for="phone">Phone number</label>
<input id="phone" type="tel" aria-invalid="true" aria-describedby="phone-error">
<p id="phone-error" class="error">
  <span aria-hidden="true">⚠</span> Enter a 10-digit number starting 6 to 9.
</p>

A red border alone says nothing. aria-invalid tells assistive technology the field is wrong, aria-describedby attaches the message so it is read when the field is focused, and the message says what to do rather than that something is invalid.

Testing colour vision

The devtools Rendering panel emulates protanopia, deuteranopia, tritanopia, achromatopsia and blurred vision. Switch each on and look at your page.

What you are looking for is not "does it look odd" — it is "has any information disappeared". Can you still tell which field is in error? Which items are in stock? Which words are links? Which line of the chart is which?

Achromatopsia — full greyscale — is the fastest single check. If the page still works with no colour at all, it works for every colour vision deficiency.

And check contrast while emulating, because some pairs that pass in full colour fail in greyscale.

A palette that works

Rather than fixing colours one at a time, define them once with contrast in mind — which is what the custom properties in modules 2 and 4 were building towards:

:root {
  --bg: #ffffff;
  --surface: #f7f7f7;
  --text: #1f1f1f;        /* 16.5:1 on --bg */
  --text-muted: #5c5c5c;  /* 6.7:1  — passes, unlike a lighter grey */
  --link: #1a4d2e;        /* 9.8:1 */
  --error: #b3261e;       /* 6.5:1 */
  --success: #1a4d2e;     /* same as link — never the only signal */
  --border: #767676;      /* 4.5:1 — for boundaries that must be seen */
  --focus: #1a4d2e;
}

Write the ratio in a comment next to each. It takes a minute, and it means the next person changing a colour can see immediately what the constraint was.

Then check the same set again in dark mode, because the numbers are all different.

Check your work

The two separate problems. Contrast, and relying on colour to carry meaning.

The AA thresholds. 4.5:1 body, 3:1 large text and component boundaries.

What counts as large. 24px, or 18.66px and bold — so a 20px subheading is body text.

Whether placeholders are exempt. No.

The lightest passing grey on white. #767676; #777777 fails.

Why green matters most. It carries 0.7152 of the luminance coefficient, so green and yellow are light — #00ff00 is 1.37:1 on white — while blue is dark and passes on white but fails on black. Pure red on white is 4.00:1 and fails.

Where to check text over a photo. The lightest and darkest parts, not the middle.

Why dark mode needs its own check. The two ends are not symmetrical — #5c5c5c is 6.69:1 on white and 2.67:1 on #14181a.

The colour rule. Colour may reinforce, never carry.

The most common colour-only failure. Unstyled links in body text — keep them underlined.

What a form error needs beyond red. aria-invalid, aria-describedby, and a message saying what to do.

The fastest colour check. Greyscale — if it works with no colour, it works for every deficiency.

What to look for when emulating. Not whether it looks odd, but whether information has disappeared.

Practice

  1. Check your body text's contrast in devtools. Then your muted text. Then your placeholders.
  2. Compute ratio("#767676", "#ffffff") and ratio("#777777", "#ffffff") with the console snippet.
  3. Find the lightest grey that passes 4.5:1 on your own background colour.
  4. Compute the ratios for pure green, yellow, red and blue against both white and black. Explain all eight results using the luminance coefficients, and note which one surprised you.
  5. Check color: red on white against the 4.5:1 threshold.
  6. Use the devtools colour picker's AA line to fix a failing colour with the smallest change.
  7. Put white text over a photograph and check the contrast at the lightest and darkest parts.
  8. Check your muted grey in light mode and then dark mode. Find one that passes in one and fails in the other.
  9. Find a status indicator on your page that uses colour alone and add text.
  10. Remove underlines from links in a paragraph, then emulate deuteranopia and try to find the links.
  11. Add aria-invalid and aria-describedby to a form error and listen to the field with a screen reader.
  12. Emulate achromatopsia on your whole page. List every piece of information that disappeared.
  13. Write your palette as custom properties with the ratio in a comment on each line.
  14. Do the same for the dark palette and note how many values had to change.
  15. Take a chart or graph — anywhere — and check whether its series can be told apart in greyscale.

Official documentation

Next: ARIA, and the five rules for using almost none of it.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship