You can write rgb(255, 99, 71) in a stylesheet, copy it into a designer tool, or paste it into a generator, and you will usually see the same tomato-orange on screen. What is less obvious is how that triplet gets flattened into a six-character token, why certain transitions look jumpy when you bump a single channel, and where the conversion quietly lies to you. This article walks through the arithmetic, the edge cases, and the small mental models that make sRGB-to-hex work less surprising in production.
If you only need a one-line answer, Lizely’s how to convert RGB to HEX: a quick practical guide covers the immediate workflow. The remainder here is for engineers who want to understand what their tools are doing underneath.
The Channel Layout and Why Two Digits Are Enough
A web color token like #FF6347 is a packed representation of three eight-bit integers, one per channel: red, green, blue. Each channel ranges from 0 to 255 inclusive, which is exactly the range of an unsigned byte. Two hexadecimal characters can express values from 00 to FF, which is 0 to 255 in base 10, so the mapping is lossless. There is no rounding or quantization inside the conversion itself; the math is a simple base change per channel.
In code it usually looks like this:
function rgbToHex(r, g, b) {
const toHex = (n) => n.toString(16).padStart(2, "0").toUpperCase();
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
Enter fullscreen mode Exit fullscreen mode
That tiny function is the entire algorithm. Every online converter, every library helper, and every designer’s eyedropper runs essentially this same logic. The Web specification itself documents the #RRGGBB syntax in the CSS Color Module, which is the stable reference for parsing rules in browsers and authoring tools (CSS Color Module Level 4, W3C).
The shorthand #RGB is just an expansion rule: #abc means #aabbcc. Each nibble is duplicated. This is purely a typing convenience, and you should never rely on it when piping values through a pipeline that compares strings exactly — #123 and #112233 are the same paint but different strings.
Where the Math Stops Being Honest
The base-16 conversion is exact. The dishonesty lives in the source numbers, not the destination format.
1. Out-of-gamut triples. If a designer hands you rgb(300, -10, 50) from a tool that allows HDR or extended values, the hex string simply cannot represent it. Every well-behaved function clamps to the [0, 255] window before converting. If yours does not, you will produce nonsense like #12C after the negative becomes a wraparound in some languages, or throw in stricter ones. Always validate the input range first.
2. Float inputs. Some pipelines pass 0.5 instead of 1 because the upstream system is normalized to [0, 1]. Multiplying by 255 and rounding is the safe path. The rounding mode matters: floor at 0.5 will give you a slightly darker image than round-half-to-even after thousands of pixels. If you are converting an entire raster of pixels, accumulate the rounding bias and pick a strategy that is consistent across the batch.
3. Implicit color space. RGB is not a single thing. rgb(255, 0, 0) in sRGB, Display P3, and ProPhoto RGB all describe physically different lights. CSS only treats unprefixed rgb() as sRGB. If you copy a triplet from a wide-gamut monitor’s color picker into a web stylesheet without conversion, you will get a mismatch. The Wikipedia entry on sRGB summarizes the gamut and transfer function, which is the right reading before you start debugging “why does my orange look duller on the laptop.”
A Conversion Checklist You Can Paste in a PR
Before you commit a new helper that maps color tuples to tokens, run it through this list:
- Inputs are clamped to
[0, 255]integers. Floats are rounded explicitly, not by accident of the language. - The output is always seven characters:
#plus six uppercase hex digits, regardless of whether the input had leading zeros. - The helper accepts both arrays and objects with
r,g,bkeys, so it does not force a reshape at every call site. - A reverse helper (
hexToRgb) is colocated and shares the same normalization rules. - Unit tests cover the boundaries:
0,0,0,255,255,255,1,1,1,15,15,15(verifies zero-padding), and at least one mid-range value likergb(173, 216, 230). - The function name and module path make it obvious whether the assumed space is sRGB. If you also need Display P3, name it explicitly.
If any item fails, the bug will eventually show up as a one-off shade difference that no designer can name and no alert catches.
Common Edge Cases and How to Spot Them
Empty channels. #000000 is pure black. If your generator ever produces a shorter string for (0, 0, 0), you have a bug in the zero-padding step.
Trick inputs that round to identical hex. rgb(255.4, 0, 0) and rgb(255.6, 0, 0) both become #FF0000. This is intentional but it means the hex token is not a hash of the source value; it is a hash of the quantized value. If your tests assert on hex strings fed from floats, pick representative numbers and assert on the rounded form.
Mixed case. Browsers and most tools accept #ff0000, #FF0000, and #Ff0000 interchangeably. Pick a canonical casing for your codebase and enforce it in lint, or you will end up with diff noise whenever two engineers touch the same palette file.
Strings with whitespace. Design tools frequently paste #FF 63 47 or #FF6347 with a trailing space. Strip before parsing. The CSS spec is explicit that whitespace inside the token is not allowed outside the rgb() functional form.
Reading a Token Back, Carefully
The reverse path is just as common. If you have #3A7BD5 and want the tuple for a canvas operation, the steps are:
- Strip the leading
#if present. - If the remaining string is three characters long, expand each character to a pair (
#abc→aabbcc). - Parse pairs two characters at a time, base-16 to decimal.
- Return as a
{r, g, b}object or three-element array, consistent with your codebase convention.
The shorthand expansion is the part most homegrown parsers miss. They will happily parse #abc as (0xa, 0xb, 0xc) instead of (170, 187, 204) and silently darken everything by a factor of sixteen.
Why This Matters in Real Systems
Three situations where the details earn their keep:
- Theme generation. When you interpolate between two brand colors and emit a palette of intermediate shades, the conversion runs once per stop, and the cumulative rounding error across the palette becomes visible. Keeping the math in floats until the final emission step avoids banding.
-
Snapshot tests. A frontend test that asserts
expect(button).toHaveStyle({ background: "#3478f6" })will fail the day someone re-saves the color from a designer tool with a slightly different alpha-aware workflow. Compare in a normalized form (uppercase, always six characters) or compare tuples, not tokens. - Cross-team handoff. When engineering receives a Figma library and exports variables as hex, the values pass through Figma’s color picker, which rounds at a different stage than your CSS preprocessor. If the brand team complains about a one-bit drift, the cause is almost always a different rounding moment, not a different intent.
Frequently asked questions
Does the # symbol have any meaning in the value itself?
No. It is a syntactic prefix that signals the rest of the token is hexadecimal. Some tooling accepts RRGGBB without it; CSS does not. Always include it for compatibility.
Why is each channel limited to 255 rather than 100?
Because web color tokens are tied to eight-bit storage per channel, matching the depth of a standard display pipeline. The 0–100 range is used by tools like HSL where it is more intuitive, but the final hex always reflects 0–255.
Can a hex token represent transparency?
The six-character form cannot. Transparency requires the eight-character #RRGGBBAA form, where the last pair is the alpha channel from 00 (transparent) to FF (opaque). Older browsers may not parse the eight-character form, so check your support matrix before relying on it.
What is the difference between #FF0000 and red?
They describe the same sRGB value. Named colors are convenience aliases defined in the CSS specification, and there are exactly 147 of them. For palette work, prefer hex so the intent is unambiguous and no one has to look up whether darkgray includes a space or a hyphen.
This article was drafted with AI assistance and reviewed for technical accuracy before publishing.