EthicsComputerCourses
Join training +91 90532 10052

Colour Theory & Accessible Contrast

UI/UX Design · Lesson 11 of 30 Intermediate 6 min read Updated

By the end of this lesson you will be able to build a small colour palette for an app, use colour to guide attention, and check that every text colour passes the WCAG contrast rules.

Colour is the first thing people notice and the easiest thing to get wrong. A good palette makes a product feel trustworthy and makes important actions obvious. A careless one makes text hard to read and hides meaning from people who see colour differently.

Hue, saturation and lightness

Every colour on a screen can be described with three plain ideas:

  • Hue: the colour family itself, like red, yellow, green or blue. On a colour wheel it is an angle from 0 to 360 degrees.
  • Saturation: how strong or pure the colour is. Low saturation looks greyish; high saturation looks vivid.
  • Lightness: how close the colour is to white or black.

Think of turmeric in a kitchen. Pure turmeric powder is a strong, saturated yellow. Mix a pinch into milk and you get the same hue, but lighter and softer. Designers create palettes the same way: one hue, many lightness steps.

CSS lets you write colours this way with hsl() or the newer oklch(). oklch() is designed so that equal lightness numbers look equally light across hues, which makes building palettes easier.

Build a palette

A practical interface palette has three parts:

  1. Brand or primary colour: used for main actions and key highlights. One hue, with a range of lighter and darker shades.
  2. Neutrals: greys for text, borders and backgrounds. These cover most of the screen.
  3. Semantic colours: colours with a fixed meaning. Success (green), warning (amber), error (red) and information (blue).

Run this example. It generates nine shades of one purple hue using oklch().

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Palette from one hue</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; color: #1a1a2e; }
    .row { display: grid; grid-template-columns: repeat(9, 1fr); gap: 4px; max-width: 560px; }
    .swatch { height: 64px; border-radius: 6px; display: grid; align-items: end; padding: 4px; font-size: 11px; }
  </style>
</head>
<body>
  <h1>Primary: hue 300</h1>
  <div class="row" id="row"></div>
  <script>
    const hue = 300;
    const steps = [0.96, 0.9, 0.82, 0.72, 0.62, 0.52, 0.44, 0.36, 0.28];
    const row = document.getElementById('row');
    steps.forEach((l, i) => {
      const div = document.createElement('div');
      div.className = 'swatch';
      div.style.background = `oklch(${l} 0.18 ${hue})`;
      div.style.color = l > 0.6 ? '#1a1a2e' : '#fff';
      div.textContent = (i + 1) * 100;
      row.append(div);
    });
  </script>
</body>
</html>

You should see nine swatches from very pale to very dark purple, labelled 100 to 900. Change hue to 150 for green or 25 for a warm red and run again. This numbered-shade idea is how many design systems name colours.

The 60-30-10 rule

A simple guide for balance, borrowed from interior design:

  • About 60% of the screen: a neutral base (white or a very light grey background).
  • About 30%: a secondary colour (cards, sections, darker neutrals).
  • About 10%: the accent (your primary colour on buttons and key links).

It is a starting point, not a law. The idea is that accent colour works because it is rare. If every element is purple, the Book button no longer stands out.

Colour meaning and culture

Colours carry meaning, and meaning depends on culture. Red means danger or error in most interfaces. In India, red is also the colour of weddings and festivals. Green often means success or "go". In a finance app, green and red for gains and losses are expected.

Use semantic colours consistently. If red means error on one screen, do not use red for a "Sale" badge on the next, or people will think something went wrong.

Accessible contrast

Contrast ratio measures the difference in brightness between text and its background, from 1:1 (no difference) to 21:1 (black on white). The international accessibility guidelines, WCAG 2.2 (Web Content Accessibility Guidelines), set minimums at level AA:

WhatMinimum ratio
Normal text4.5:1
Large text (about 24px, or about 18.66px bold, and larger)3:1
Icons, input borders, focus rings and other UI parts people need to see3:1 against neighbouring colours

The full formula and its details are explained in the accessibility course's Colour & Contrast lesson. Here you can try a live checker.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contrast checker</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; color: #1a1a2e; max-width: 420px; }
    label { margin-right: 12px; }
    #sample { margin: 16px 0; padding: 20px; border-radius: 8px; font-size: 16px; }
    #result { font-weight: 700; }
  </style>
</head>
<body>
  <label>Text <input type="color" id="fg" value="#999999"></label>
  <label>Background <input type="color" id="bg" value="#ffffff"></label>
  <p id="sample">Your appointment is confirmed for 9:00 am.</p>
  <p id="result"></p>
  <script>
    function luminance(hex) {
      const c = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
        .map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
      return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
    }
    function update() {
      const fg = document.getElementById('fg').value;
      const bg = document.getElementById('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.getElementById('sample');
      sample.style.color = fg;
      sample.style.background = bg;
      const verdict = ratio >= 4.5 ? 'passes AA for all text' : ratio >= 3 ? 'large text and UI parts only' : 'fails';
      document.getElementById('result').textContent = `Ratio ${ratio.toFixed(2)}:1, ${verdict}`;
    }
    document.querySelectorAll('input').forEach(i => i.addEventListener('input', update));
    update();
  </script>
</body>
</html>

You should see "Ratio 2.85:1, fails" for light grey #999999 on white. Pick a darker grey. Around #767676 the ratio crosses 4.5:1 and the verdict changes. This is why light grey body text is such a common accessibility bug.

Never use colour alone

Some people see red and green very similarly. This is common, especially among men. Bright sunlight on a phone screen also washes colours out for everyone. So colour must never be the only signal.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Colour plus text and icon</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; display: grid; gap: 12px; max-width: 360px; }
    .status { padding: 12px; border-radius: 8px; }
    .only-colour.ok { background: #dcfce7; }
    .only-colour.err { background: #fee2e2; }
    .clear { display: flex; gap: 8px; align-items: center; font-weight: 600; }
    .clear.ok { background: #dcfce7; color: #14532d; border-left: 4px solid #15803d; }
    .clear.err { background: #fee2e2; color: #7f1d1d; border-left: 4px solid #b91c1c; }
  </style>
</head>
<body>
  <div class="status only-colour ok">₹500 · 12 Nov</div>
  <div class="status only-colour err">₹500 · 13 Nov</div>
  <div class="status clear ok"><span aria-hidden="true">✓</span> Paid: ₹500 on 12 Nov</div>
  <div class="status clear err"><span aria-hidden="true">✕</span> Failed: ₹500 on 13 Nov. Money not deducted.</div>
</body>
</html>

The first two rows rely only on pale green and pale red. The last two add a word, an icon and a stronger edge. Anyone can tell which payment failed.

Common mistakes

  • Light grey text for "elegance" that fails 4.5:1.
  • Too many accent colours. Pick one primary and keep semantic colours for meaning.
  • Colour-only status, like a red border with no error message.
  • Testing only on a bright, calibrated monitor. Check on a cheap phone in daylight too.

Key points

  • Describe colours by hue, saturation and lightness; build shades from one hue.
  • A palette needs a primary colour, neutrals and semantic colours.
  • The 60-30-10 rule keeps the accent rare, so it stands out.
  • WCAG AA needs 4.5:1 for normal text, 3:1 for large text and important UI parts.
  • Never use colour as the only way to show meaning; add text or icons.

Exercise

Use the contrast checker to find the lightest grey that still passes 4.5:1 on white, and the lightest purple that passes on white. Then change the palette example to a green hue and decide which shade numbers could hold white text at 4.5:1 (check them in the checker).

Show solution
Text
Lightest passing grey on white: about #767676 (4.54:1)
#777777 gives about 4.48:1 and fails.
A purple such as #7c3aed passes with about 5.7:1.

For the green palette, use the checker on the darker shades: the 600 to 900 steps usually pass with white text, while the lighter steps need dark text instead. Always confirm with the checker rather than by eye.

Quick Quiz

1. What is the WCAG AA minimum contrast for normal body text?

2. In the 60-30-10 rule, what gets about 10%?

3. Which status message is best for people who cannot tell red from green?

Learn UI/UX Design with a mentor

Free lessons get you started. Our UI/UX Design training adds real projects and code reviews from working developers.

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