← Back to Blog

Colour Codes in Practice: Converting Between HEX, RGB and HSL, and Computing WCAG Contrast

The conversion formulas for colour codes are a search away, but two higher-level questions are what actually block people: which coordinate system to use when, and how to judge whether a pair genuinely complies. This article covers those; live conversion is available in the in-site colour converter.

Three systems exist, each with a role. A table of channels: HEX (#RRGGBB) for literals in source code, RGB (0–255 per channel) when you want to change numeric values directly, and HSL (H 0–360°, S, L 0–100%) for adjusting, designing and generating palettes. HEX is merely a hexadecimal shorthand for RGB — #FF8800 is R=255, G=136, B=0 — so conversion between them is pure base arithmetic with no computation involved. HSL is the genuinely different perspective. RGB describes how much light each channel emits; HSL describes what hue the colour is, how saturated it is and how light it is. The difference shows up immediately when adjusting: in HSL, raising L from 50 to 70 is understood as brightening, while in RGB, moving R from 200 to 230 tells you nothing about the outcome, because it may lighten, darken or shift hue. RGB channels are coupled, which makes RGB unsuitable for "change only the lightness" operations.

For contrast, the most common error is judging it from HSL lightness. WCAG contrast goes through relative luminance, not HSL: inverse-gamma each sRGB channel, compute 0.2126R + 0.7152G + 0.0722B, then take (Lmax + 0.05) / (Lmin + 0.05). The thresholds are 4.5:1 for AA body text and 7:1 for AAA, dropping to 3:1 and 4.5:1 for large text (18pt, or 14pt bold). HSL lightness fails because L is a normalised value shared across hues while luminance is physical: pure yellow #FFFF00 has HSL L=50% but relative luminance 0.928, giving only 1.07:1 on white and making it unreadable, whereas pure blue #0000FF also sits at L=50% but reaches 8.59:1 and comfortably clears AA. Mid grey at L=50% lands at 0.184, or 4.73:1. So when a single brand colour needs light and dark text variants, both pairs must be measured rather than assumed symmetric from equal lightness.

A concrete borderline case on white: #767676 yields 4.54:1 and passes AA for body text, while #777777 yields 4.48:1 and fails. One step in a single hex value flips compliance, which is exactly why button text colours belong in design tokens rather than being written by hand — manual colour adjustment easily misses that step.

The three conversions, with code

HEX to RGB is pure base arithmetic:

const n = parseInt(hex.slice(1), 16)
const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255]

and back:

const hex = '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join('')

RGB to HSL normalises, then works from max and min:

function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255
  const max = Math.max(r, g, b), min = Math.min(r, g, b)
  const l = (max + min) / 2
  if (max === min) return { h: 0, s: 0, l }        // pure grey
  const d = max - min
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
  let h
  if (max === r)      h = (g - b) / d + (g < b ? 6 : 0)
  else if (max === g) h = (b - r) / d + 2
  else                h = (r - g) / d + 4
  return { h: h * 60, s, l }
}

HSL to RGB reverses it, with a branch worth noticing:

function hslToRgb(h, s, l) {
  h = (((h % 360) + 360) % 360) / 360
  if (s === 0) { const v = Math.round(l * 255); return [v, v, v] }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s
  const p = 2 * l - q
  const f = t => {
    t = (t + 1) % 1
    if (t < 1 / 6) return p + (q - p) * 6 * t
    if (t < 1 / 2) return q
    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
    return p
  }
  return [f(h + 1 / 3), f(h), f(h - 1 / 3)].map(v => Math.round(v * 255))
}

The saturation branch in rgbToHsl is the part people get wrong: the formula changes shape above L = 0.5 because the midpoint inverts which side carries the chroma. Getting it backwards yields wrong saturation for light colours, which then propagates into every palette built from it.

Designing a palette that stays readable

A palette generator is not simply a colour picker with more clicks. The useful ones derive a full set from a base hue: an analogous set at 60-degree intervals for harmony, then lightness and saturation ramps for backgrounds, surfaces, text and accents. What separates a merely pretty palette from an accessible one is whether the generator constrains contrast rather than leaving it to chance.

Two rules of thumb when building a palette by hand:

  1. Pick the accent first, then derive neutrals from its hue. Adding a slight tint of the accent hue to grey backgrounds makes the whole interface feel coherent; pure grey next to a saturated accent looks disconnected.
  2. Define text colours as tokens, not literals. A token like --t-text-muted can be tuned once and reused across light and dark themes, and it can be checked against every background it appears on. Literal hex values scattered through components are where a one-step compliance failure like #777777 hides.

A useful demonstration: enter pure yellow #FFFF00 into the colour converter and switch to HSL. L reads 50%, yet the contrast column shows 1.07:1 — the cleanest possible proof that HSL lightness is not luminance. Then move the same hue to L=30% and L=70% and observe that contrast changes far from linearly, so intuition is unreliable. A palette generator produces sets that already include variants meeting body-text contrast.

Advertisement

Frequently Asked Questions

Which of HEX, RGB and HSL should I use?

**The use case decides; none is inherently better.** **HEX** is the most compact notation (#FF8800) and belongs in source code literals. **RGB** is the device-native model with 0–255 channels, ideal when you want to nudge numeric values directly. **HSL/HSB** is a **human-perception model** — hue 0–360°, saturation, lightness 0–100% — whose three axes correspond to what the eye actually distinguishes, so **adjusting colours in HSL is far more predictable**: raising L from 50 to 70 is understood as brightening, whereas moving R from 200 to 230 in RGB tells you nothing about the result. The working rule: **write HEX in code, think in RGB when programming, work in HSL when adjusting and designing.** A colour converter handles any conversion.

How is WCAG contrast computed, and how far apart are AA and AAA?

Contrast is not a simple subtraction of RGB values; it goes through **relative luminance**. Apply inverse gamma correction to bring each sRGB channel from 0–1 to linear, compute luminance as 0.2126R + 0.7152G + 0.0722B, do the same for the background, then take ratio = (Lmax + 0.05) / (Lmin + 0.05). The thresholds are **AA: 4.5:1 for body text, 3:1 for large text; AAA: 7:1 and 4.5:1 respectively.** Large text means 18pt, or 14pt bold. Judging contrast from HSL lightness is **wrong**: pure colours at L=100 versus L=80 differ in luminance far more than intuition suggests, and yellow already clears the bar at L=50.

A single brand colour with light and dark text variants — which do I pick?

**Only one of the two will work, and you must measure it.** Within one hue, a dark text colour and a light text colour can have similar lightness yet opposite compliance outcomes. The most common failure is assuming that **equal HSL lightness means equal darkness-lightness pairing** — but L means something entirely different per hue, since yellow at L=50 is already bright while blue at L=50 stays dark. The correct move is to **compute contrast for each foreground/background pair** rather than guessing from hue or lightness. A colour converter reports the WCAG level directly, and a palette generator produces sets that already respect necessary contrast constraints.

← Back to Blog