Helper functions
Color conversion formulas look simple, but in code they trip over details: 360° instead of 0°, negative modulo, a value of 255.5, or a gray for which no hue exists. This page collects the functions that all the other conversions in this section rely on.
Clamping to a range
Conversions often produce results slightly outside 0..1, from rounding errors or because the color is out of gamut (see CIE XYZ and RGB).
const clamp = (x, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, x));
Use clamping deliberately. For a rounding error (e.g. 1.0000001) it is right. For an out-of-gamut color (e.g. R = -0.17) it changes the hue, and you should at least be aware of it.
Modulo for angles
In JavaScript, C and Java the % operator keeps the sign of the dividend: -30 % 360 gives -30, not 330. A hue angle has to be normalized like this:
const wrapHue = (h) => ((h % 360) + 360) % 360;
wrapHue(-30) // 330
wrapHue(370) // 10
wrapHue(360) // 0
In Python % already works correctly for a positive divisor.
Max, min and chroma
Almost every conversion from RGB starts with these three values:
When d = 0 the color is a gray and the hue is undefined. Code must check this before dividing by d. With floating point, compare with a small tolerance (e.g. d < 1e-9) rather than exact zero.
The classic hue2rgb function
In many libraries the HSL to RGB conversion looks like this, with helper values p and q:
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 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;
}
function hslToRgb(h, s, l) { // h in 0..1
if (s === 0) return { r: l, g: l, b: l };
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return { r: hue2rgb(p, q, h + 1 / 3), g: hue2rgb(p, q, h), b: hue2rgb(p, q, h - 1 / 3) };
}
q is the largest output component, p the smallest. hue2rgb draws a "trapezoid" between them: for 1/6 of the wheel the value rises from p to q, for 1/3 it equals q, for 1/6 it falls, and for the rest it equals p. Each component is the same trapezoid shifted by 1/3 of the wheel. For HSL (15°, 66.7%, 47.1%) you get p = 0.1569 and q = 0.7843, and the result is (200, 80, 40).
The shorter absolute-value formula from the hue angle to RGB page gives the same result. Both are correct. It helps to recognize both, because you will meet each in other people's code.
Rounding to bytes
const toByte = (c) => Math.round(clamp(c) * 255);
const fromByte = (n) => n / 255;
Multiply by 255, not 256, and round rather than truncate. Math.floor(c * 256) also turns up, but it gives 256 for c = 1 and shifts every value. After a round trip (RGB → HSV → RGB) the result can differ from the input by 1 on one component if HSV was stored with low precision. That is normal.
HEX notation
const toHex = (r, g, b) =>
'#' + [r, g, b].map((c) => toByte(c).toString(16).padStart(2, '0')).join('');
function parseHex(s) {
let h = s.replace('#', '');
if (h.length === 3) h = [...h].map((c) => c + c).join(''); // #c52 → #cc5522
const n = parseInt(h, 16);
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
}
The sRGB transfer function
Before physical computation (mixing, XYZ, luminance) values must be linearized. Formulas and reasons are in the article on gamma correction:
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
All these functions, together with conversions to HSV, HSL, HSI, CMYK, XYZ, Lab and Luv, are in this site's calculator script: /assets/color.js.