When you need the channels, not the string
rgb(102, 51, 153) and #663399 are the same color. The difference is that one of them is a value you can do arithmetic on. You want the numbers whenever a color is an input rather than a label — and when the color arrived as a hex string from a design file in the first place, HEX to RGB is this same converter pointed the other way, with the case for why the handover takes that form.
- Canvas and WebGL.
fillStyle takes a string, but pixel work does not: ImageData is a flat array of channel bytes, and anything that tints, blends or samples operates on those.
- Interpolation. Gradients, chart scales and color ramps mix channel by channel, so they need the endpoints as numbers.
- Contrast. WCAG relative luminance is defined on linearized channel values. There is no way to compute a ratio from a hex string without splitting it first, which is what the panel above is doing.
- Anything generated. Tint and shade ladders, theme builders, palette generators — all of them start from three numbers.
The custom property trick worth knowing
This is why a lot of design systems store colors as bare channels rather than as hex. Set --brand: 102 51 153, then write background: rgb(var(--brand)) for the solid color and background: rgb(var(--brand) / 12%) for a tint of it. One token serves the color and every transparency built from it.
The version people try first does not work: if --brand holds rgb(102, 51, 153), then rgba(var(--brand), .12) expands to rgba(rgb(102, 51, 153), .12), which is invalid and silently drops the declaration. Store the three numbers, not the function call. The RGB row above gives them to you with commas — delete those for the custom property form.
Old syntax and new
Modern CSS takes rgb(102 51 153 / 40%), with spaces between channels and a slash before alpha. rgba() is now simply an alias for rgb(), kept for compatibility, and current browsers accept either separator style in either function. Percentages work as well, and values outside 0 to 255 are clamped rather than rejected, which is why 300 becomes 255 here too instead of throwing an error at you.
Two things that catch people out
Round-tripping through HSL is lossy. hsl() lands on integer degrees and percentages, so #2F80ED converted to HSL and back comes out as #3182ED. Visually identical, not byte identical, and a diff or a visual regression test will flag it. Convert once from the source value rather than through a chain of formats.
Averaging channels gives muddy midpoints. A blue-to-yellow gradient interpolated in sRGB passes through gray, because the two colors cancel channel by channel. When the midpoint matters, let CSS mix somewhere better — color-mix(in oklab, …), or a gradient declared in oklch — and keep straight channel maths for the jobs that are genuinely arithmetic.