Your designer hands you the brand orange as #FF6B00. The design system wants it in HSL, and the canvas code you’re writing wants three numbers from 0 to 255. Same color, three spellings: #FF6B00, rgb(255, 107, 0) and hsl(25, 100%, 50%).
They all describe exactly the same color. What differs is how easy each one is to read and tweak, and that’s what decides which one you reach for.
HEX: the #RRGGBB format
A HEX color is # followed by three pairs of hexadecimal digits for the red, green and blue channels. Each pair runs from 00 to FF, which is 0 to 255 in decimal.
#FF6B00
RR = FF = 255
GG = 6B = 107
BB = 00 = 0
#000000 black
#FFFFFF white
#808080 medium gray (equal R, G and B)
#F60 shorthand for #FF6600
#333 shorthand for #333333
#FF6B0080 the same orange with alpha 80 (128 of 255, about 50%)HEX is compact and is what most design tools and color pickers give you. It’s hard to adjust by hand, though. Nobody looks at 6B and thinks “a touch more green, please”.
RGB: three channels from 0 to 255
RGB gives the amount of red, green and blue light, each from 0 to 255. It's the same information as HEX written in decimal, and it matches how screens and image libraries store pixels.
color: rgb(255, 107, 0);
color: rgb(255 107 0); /* modern space-separated syntax */
/* With alpha */
color: rgba(255, 107, 0, 0.5);
color: rgb(255 107 0 / 50%); /* same result */
/* Percentages are also allowed */
color: rgb(100% 42% 0%);HSL: hue, saturation, lightness
HSL describes a color in terms closer to how people talk about it:
- Hue (0 to 360) is the angle on the color wheel: 0 is red, 120 is green, 240 is blue.
- Saturation (0 to 100%) is how vivid the color is. 0% is gray.
- Lightness (0 to 100%) runs from black at 0% to white at 100%. 50% gives the most vivid version of the hue.
color: hsl(25, 100%, 50%); /* base orange */
color: hsl(25, 100%, 70%); /* lighter: raise lightness */
color: hsl(25, 100%, 30%); /* darker: lower lightness */
color: hsl(25, 50%, 50%); /* muted: lower saturation */
color: hsl(25, 100%, 90%); /* pale tint for backgrounds */
/* Hues around the wheel at full saturation */
hsl(0 100% 50%) /* red */
hsl(60 100% 50%) /* yellow */
hsl(120 100% 50%) /* green */
hsl(180 100% 50%) /* cyan */
hsl(240 100% 50%) /* blue */
hsl(300 100% 50%) /* magenta */
/* With alpha */
color: hsla(25, 100%, 50%, 0.5);
color: hsl(25 100% 50% / 50%);Because hue is separate from lightness and saturation, HSL is the easiest model for building palettes. Need a hover state? Keep the hue and change one number.
Converting between the models
HEX to RGB
Parse each two-digit pair as a base-16 number.
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const clean = hex.replace(/^#/, "");
// Expand 3-digit shorthand: "F60" -> "FF6600"
const full =
clean.length === 3
? clean.split("").map((c) => c + c).join("")
: clean;
const num = Number.parseInt(full, 16);
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
}
hexToRgb("#FF6B00"); // { r: 255, g: 107, b: 0 }
hexToRgb("#333"); // { r: 51, g: 51, b: 51 }
hexToRgb("0088FF"); // { r: 0, g: 136, b: 255 }RGB to HEX
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (n: number) => n.toString(16).padStart(2, "0");
return "#" + toHex(r) + toHex(g) + toHex(b);
}
rgbToHex(255, 107, 0); // "#ff6b00"
rgbToHex(255, 255, 255); // "#ffffff"RGB to HSL
This one takes a little more math, but the idea is simple. Scale the channels to 0 to 1. Lightness is the average of the largest and smallest channel, saturation depends on their difference, and hue depends on which channel is largest.
function rgbToHsl(
r: number, g: number, b: number
): { h: number; s: number; l: number } {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
const l = (max + min) / 2;
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
let h = 0;
if (delta !== 0) {
if (max === r) h = 60 * (((g - b) / delta) % 6);
else if (max === g) h = 60 * ((b - r) / delta + 2);
else h = 60 * ((r - g) / delta + 4);
}
if (h < 0) h += 360;
return {
h: Math.round(h),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
rgbToHsl(255, 107, 0); // { h: 25, s: 100, l: 50 }
rgbToHsl(128, 128, 128); // { h: 0, s: 0, l: 50 }
rgbToHsl(0, 136, 255); // { h: 208, s: 100, l: 50 }HSL to RGB
function hslToRgb(
h: number, s: number, l: number
): { r: number; g: number; b: number } {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s; // chroma
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r1 = 0, g1 = 0, b1 = 0;
if (h < 60) { r1 = c; g1 = x; }
else if (h < 120) { r1 = x; g1 = c; }
else if (h < 180) { g1 = c; b1 = x; }
else if (h < 240) { g1 = x; b1 = c; }
else if (h < 300) { r1 = x; b1 = c; }
else { r1 = c; b1 = x; }
return {
r: Math.round((r1 + m) * 255),
g: Math.round((g1 + m) * 255),
b: Math.round((b1 + m) * 255),
};
}
hslToRgb(25, 100, 50); // { r: 255, g: 106, b: 0 }
hslToRgb(0, 0, 50); // { r: 128, g: 128, b: 128 }
hslToRgb(208, 100, 50); // { r: 0, g: 136, b: 255 }Note that hsl(25, 100%, 50%) comes back as #FF6A00, not the original #FF6B00. HSL values are usually rounded to whole numbers, so a round trip through HSL can shift a channel by one.
Which format to use in CSS
- HEX for fixed values copied from a design file or brand guide.
- RGB when you work with individual channels in code, for example on a canvas or in image processing.
- HSL for design systems, themes and state variations such as hover, active and disabled.
All three support transparency, so alpha is no longer a reason to pick one over another.
:root {
--brand-hue: 25;
--brand-sat: 100%;
--brand-100: hsl(var(--brand-hue) var(--brand-sat) 90%);
--brand-300: hsl(var(--brand-hue) var(--brand-sat) 70%);
--brand-500: hsl(var(--brand-hue) var(--brand-sat) 50%);
--brand-700: hsl(var(--brand-hue) var(--brand-sat) 30%);
--brand-900: hsl(var(--brand-hue) var(--brand-sat) 10%);
}
/* Switching the brand color is one line */
:root[data-theme="blue"] {
--brand-hue: 220;
}
.button {
background: hsl(var(--brand-hue) var(--brand-sat) 50%);
}
.button:hover {
background: hsl(var(--brand-hue) var(--brand-sat) 45%);
}
.button:disabled {
background: hsl(var(--brand-hue) 20% 70%);
}Color contrast and accessibility
The Web Content Accessibility Guidelines (WCAG) set minimum contrast ratios between text and its background:
- AA: at least 4.5:1 for normal text and 3:1 for large text (24px regular, or about 18.7px bold).
- AAA: at least 7:1 for normal text and 4.5:1 for large text.
The ratio is computed from each color’s relative luminance, which is how bright it looks to the human eye, worked out from its RGB values:
function relativeLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
type Rgb = [number, number, number];
function contrastRatio(a: Rgb, b: Rgb): number {
const l1 = relativeLuminance(...a);
const l2 = relativeLuminance(...b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
const white: Rgb = [255, 255, 255];
contrastRatio([255, 107, 0], white); // about 2.9: fails AA for any text size
contrastRatio([51, 51, 51], white); // about 12.6: passes AAAHSL lightness isn't a reliable guide to contrast. Yellow and blue at the same lightness look nothing alike in brightness, because your eyes are far more sensitive to green and yellow light. Always check the actual ratio.
Quick reference
| Feature | HEX | RGB | HSL |
|---|---|---|---|
| Format | #RRGGBB | rgb(R, G, B) | hsl(H, S%, L%) |
| Range | 00 to FF per channel | 0 to 255 per channel | 0 to 360, 0 to 100%, 0 to 100% |
| Easy to adjust by hand | No | Somewhat | Yes |
| Alpha | #RRGGBBAA | rgb(R G B / A) | hsl(H S L / A) |
| Best for | Fixed values | Channel math in code | Palettes and themes |
