EthicsComputerCourses
Join training +91 90532 10052

Colour & Contrast

Web Accessibility · Lesson 15 of 28 Intermediate 6 min read Updated

Pale grey text on a white background may look elegant on a designer's big monitor, but on a phone in Jaipur sunlight or for someone with low vision it is unreadable. Contrast is the difference in brightness between text (or an icon) and its background. WCAG turns it into a number called the contrast ratio, so you can test it instead of guessing.

In this lesson you will learn the contrast rules, see the exact formula, compute ratios with a short Python script and a live checker, and learn why colour must never be the only way to show meaning.

The contrast rules

CriterionLevelRequirement
1.4.3 Contrast (Minimum)AAText at least 4.5:1; large text at least 3:1
1.4.6 Contrast (Enhanced)AAAText at least 7:1; large text at least 4.5:1
1.4.11 Non-text ContrastAAParts of controls needed to identify them (borders of inputs, icons, focus rings) and parts of graphics needed to understand them (chart lines) at least 3:1 against adjacent colours
1.4.1 Use of ColorAColour is not the only visual means of conveying information

Large text means at least 18 point, or 14 point bold. In CSS pixels that is about 24px, or about 18.66px bold.

Some things are exempt: logos and brand names, text in disabled controls, purely decorative text, and text in photos that is incidental. Placeholder text is not exempt; if it carries information, it needs 4.5:1.

The formula

The ratio compares the relative luminance (brightness from 0 for black to 1 for white) of two colours. WCAG 2.2 defines it in three steps:

  1. Divide each sRGB channel (R, G, B from 0 to 255) by 255. If the result is at most 0.04045, divide it by 12.92. Otherwise compute ((c + 0.055) / 1.055) raised to the power 2.4.
  2. Relative luminance L = 0.2126 × R + 0.7152 × G + 0.0722 × B, using the converted channels. Green counts most because our eyes are most sensitive to it.
  3. Contrast ratio = (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter colour's luminance and L2 the darker one's.

The result runs from 1:1 (same colour) to 21:1 (black on white). The 0.05 added to both sides models light reflected from the screen and keeps the ratio from dividing by zero.

Computing ratios with Python

This script follows the formula step by step. It checks some common greys and the saffron and green of the Indian flag as button backgrounds.

Python
def channel(value: int) -> float:
    """Convert one 0-255 sRGB channel to its linear value."""
    c = value / 255
    if c <= 0.04045:
        return c / 12.92
    return ((c + 0.055) / 1.055) ** 2.4


def luminance(hex_colour: str) -> float:
    """Relative luminance of a colour such as '#0b5cad'."""
    h = hex_colour.lstrip("#")
    r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
    return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)


def contrast_ratio(fg: str, bg: str) -> float:
    lighter, darker = sorted([luminance(fg), luminance(bg)], reverse=True)
    return (lighter + 0.05) / (darker + 0.05)


def verdict(ratio: float) -> str:
    if ratio >= 7:
        return "AAA text"
    if ratio >= 4.5:
        return "AA text"
    if ratio >= 3:
        return "large text / UI only"
    return "fails"


pairs = [
    ("#000000", "#ffffff"),
    ("#767676", "#ffffff"),
    ("#999999", "#ffffff"),
    ("#ffffff", "#ff9933"),
    ("#ffffff", "#138808"),
    ("#0b5cad", "#ffffff"),
]
for fg, bg in pairs:
    ratio = contrast_ratio(fg, bg)
    print(f"{fg} on {bg}: {ratio:5.2f}:1  {verdict(ratio)}")
Output
#000000 on #ffffff: 21.00:1  AAA text
#767676 on #ffffff:  4.54:1  AA text
#999999 on #ffffff:  2.85:1  fails
#ffffff on #ff9933:  2.13:1  fails
#ffffff on #138808:  4.61:1  AA text
#0b5cad on #ffffff:  6.67:1  AA text

Notice two things. #767676 is about the lightest grey that still passes AA on white, and #999999, a very popular "subtle" grey, fails. And white text on saffron fails badly, while black text on the same saffron passes with a ratio of about 9.9:1. Brightness, not "how colourful" a colour is, decides contrast.

A live contrast checker

The same formula in JavaScript makes a small checker. Pick two colours and read the result.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contrast Checker</title>
  <style>
    body { font: 17px/1.5 system-ui, sans-serif; }
    #sample { padding: 16px; font-size: 18px; border: 1px solid #888; margin: 12px 0; max-width: 420px; }
    label { margin-right: 16px; }
  </style>
</head>
<body>
  <h1>Contrast checker</h1>
  <label>Text colour <input type="color" id="fg" value="#767676"></label>
  <label>Background <input type="color" id="bg" value="#ffffff"></label>
  <p id="sample">Diwali offer: 20% off sweets, today only</p>
  <p id="result" role="status"></p>

  <script>
    const channel = (v) => {
      const c = v / 255;
      return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
    };
    const luminance = (hex) => {
      const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
      return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
    };
    function update() {
      const fg = document.querySelector("#fg").value;
      const bg = document.querySelector("#bg").value;
      const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
      const ratio = (hi + 0.05) / (lo + 0.05);
      const sample = document.querySelector("#sample");
      sample.style.color = fg;
      sample.style.background = bg;
      const aa = ratio >= 4.5 ? "passes" : "fails";
      const aaa = ratio >= 7 ? "passes" : "fails";
      document.querySelector("#result").textContent =
        `Ratio ${ratio.toFixed(2)}:1. Normal text AA ${aa}, AAA ${aaa}. Large text AA ${ratio >= 3 ? "passes" : "fails"}.`;
    }
    document.querySelector("#fg").addEventListener("input", update);
    document.querySelector("#bg").addEventListener("input", update);
    update();
  </script>
</body>
</html>

Browser developer tools show the same ratio when you inspect text: in Chrome, the colour picker in the Styles panel shows the contrast ratio with AA and AAA lines. Use them on real pages, including hover, focus and disabled states.

Never use colour alone

Many people, more often men than women, have some form of colour vision deficiency, most often difficulty telling red from green. Others use their screens in grayscale or in bright light. WCAG 1.4.1 says colour can support meaning but must not be the only signal.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Colour Alone</title>
  <style>
    .up { color: #1b6e20; }
    .down { color: #b00020; }
    .content a { color: #0b5cad; text-decoration: underline; }
    .gray { filter: grayscale(1); }
  </style>
</head>
<body>
  <h1>Stock prices</h1>
  <h2>Colour only</h2>
  <p>Tata Steel <span class="up">₹152</span> | Infosys <span class="down">₹1,480</span></p>
  <h2>Colour plus text and symbol</h2>
  <p>Tata Steel <span class="up">▲ up ₹152</span> | Infosys <span class="down">▼ down ₹1,480</span></p>
  <p class="content">Read the <a href="#">market report</a> for details.</p>
  <button type="button" id="gray">Toggle grayscale view</button>
  <script>
    document.querySelector("#gray").addEventListener("click", () => document.body.classList.toggle("gray"));
  </script>
</body>
</html>

Press "Toggle grayscale view". In the first line you can no longer tell which price went up. The second line still works, because it says "up" and "down". The link in the paragraph is underlined, so it is visible without colour too. The prices are example numbers.

Common mistakes

  • Light grey text and placeholders such as #999 or #aaa on white.
  • Text over photos with no overlay. Add a dark semi-transparent layer or a solid background behind the text.
  • Low-contrast input borders. A #ddd border on white fails 1.4.11; users cannot see where to type.
  • Checking only the default state. Hover, focus, visited, error and dark mode colours need checking too.
  • Red and green as the only signal in charts, form validation and status badges.

Key points

  • Normal text needs at least 4.5:1 and large text 3:1 at AA; AAA needs 7:1 and 4.5:1.
  • Input borders, icons, focus indicators and chart lines need at least 3:1 (1.4.11).
  • Relative luminance converts each channel to linear light, then weights green most; the ratio is (L1 + 0.05) / (L2 + 0.05).
  • Brightness decides contrast: white on saffron fails, black on saffron passes.
  • Never use colour as the only way to show meaning; add text, icons or underlines.

Exercise

A college website uses these colours: body text #8a8a8a on white, white text on a #b00020 red "Apply now" button, and #d32f2f red error text on white. Use the Python script (or the live checker) to compute each ratio and say whether it passes AA for normal text. Suggest a fix for any that fail.

Show solution
Python
for fg, bg in [("#8a8a8a", "#ffffff"), ("#ffffff", "#b00020"), ("#d32f2f", "#ffffff")]:
    print(fg, "on", bg, round(contrast_ratio(fg, bg), 2))
Output
#8a8a8a on #ffffff 3.45
#ffffff on #b00020 7.33
#d32f2f on #ffffff 4.98

The body text fails AA (3.45 is below 4.5); darken it to #595959 or darker. The button text passes even AAA, and the error red passes AA for normal text. The error message should still begin with the word "Error" so it does not rely on colour.

Quick Quiz

1. What is the minimum contrast ratio for normal-size body text at level AA?

2. In the relative luminance formula, which channel has the largest weight?

3. Which needs at least 3:1 contrast under 1.4.11 Non-text Contrast?

4. Why does "Fields in red are compulsory" fail WCAG?

Learn Web Accessibility with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call