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:
- 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.
- Define text colours as tokens, not literals. A token like
--t-text-mutedcan 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.