8 min read1,630 words

Hex to RGB: The Maths and a Converter

Overlapping red, green and blue circles with an eyedropper

Hex colour codes are just RGB values written in base 16, but the shorthand forms and the newer colour functions in CSS mean there is more going on under the surface than the six-digit form suggests. This post covers the actual arithmetic behind hex-to-RGB conversion and where each modern colour format fits. The colour tools under /dev-tools handle the conversion directly in the browser if you just need the numbers.

colourcssconversion
Share on XHacker News

Parsing hex digits

A hex colour encodes each channel as a two-digit hexadecimal number from 00 to FF, which is 0 to 255 in decimal. #FF6600 splits into FF (red), 66 (green) and 00 (blue). To convert a pair of hex digits to decimal, multiply the first digit by 16 and add the second: F is 15, so FF is 15 times 16 plus 15, which is 255.

This is exactly what parseInt("FF", 16) does in JavaScript, and it is the core operation behind every hex-to-RGB converter, however many extra features are layered on top.

3, 4, 6 and 8 digit forms

The three-digit shorthand, like #F60, expands by duplicating each digit, so #F60 becomes #FF6600. This only works because each channel happens to use the same digit twice, so shorthand notation cannot represent every possible colour, only the 4096 colours where each channel is a multiple of 17.

A fourth digit or digit pair adds an alpha channel: #F60A is a four-digit shorthand with alpha, and #FF6600AA is the equivalent eight-digit form. Alpha in hex is also 00 to FF, but it is interpreted as a fraction of full opacity rather than a colour intensity, so FF means fully opaque and 00 means fully transparent.

  • #RGB shorthand duplicates each digit to form #RRGGBB
  • #RGBA and #RRGGBBAA add an alpha channel as a fifth or eighth pair
  • Alpha 00 is transparent, FF is opaque

Converting to HSL and back

RGB describes a colour by the intensity of red, green and blue, while HSL describes the same colour by hue, saturation and lightness, which is often more intuitive for tasks like generating a set of related colours or adjusting brightness without shifting hue. Converting from RGB to HSL involves finding the maximum and minimum of the three channels, using their difference to compute saturation, and using their position to compute hue as an angle in degrees.

The reverse conversion, HSL back to RGB, reconstructs each channel from the hue angle using a piecewise formula based on which 60-degree sector the hue falls into. Neither direction is complicated arithmetic, but it is fiddly enough that doing it by hand invites small errors, which is exactly the kind of repetitive conversion a browser-based tool removes the risk from.

Modern CSS colour functions and oklch

CSS now supports colour written directly as functions rather than only as hex or the older rgb() and hsl() syntax with commas. Space-separated syntax like rgb(255 102 0 / 80%) is now valid and lets you add alpha without a separate rgba() function.

oklch() describes a colour in the OKLCH colour space, using perceptual lightness, chroma and hue instead of RGB channels. The advantage is that colours with the same lightness value in OKLCH actually look equally bright to the human eye, which is not reliably true of HSL lightness. This matters when generating colour scales programmatically, since an OKLCH-based scale tends to look more even than an HSL-based one across different hues.

Calculating contrast ratio

Contrast ratio, as defined by WCAG, is calculated from the relative luminance of two colours: (L1 + 0.05) divided by (L2 + 0.05), where L1 is the lighter of the two luminances and L2 is the darker. Relative luminance itself is computed from linearised RGB values with fixed weighting coefficients for red, green and blue, reflecting the fact that green contributes more to perceived brightness than blue does.

A ratio of 4.5:1 is the WCAG AA threshold for normal-sized text, and 3:1 for large text. Calculating this correctly requires converting sRGB values to linear light first, which is a small but easy-to-miss step, since skipping it produces a ratio that looks plausible but is numerically wrong.

Rounding pitfalls

Converting between colour spaces repeatedly compounds rounding error. Going from hex to HSL and back to hex, for example, can shift a colour by one or two units per channel because HSL lightness and saturation are stored as percentages that do not always map back to an integer RGB value exactly.

If a design system depends on an exact hex value being preserved through a round trip, store the original hex value alongside any derived HSL or OKLCH representation rather than regenerating it from the derived form, and only round at the point of display.

When to use each format

Hex is compact and universally understood, and remains the best choice for design tokens and documentation. RGB is closest to how most tools store colour internally and is easiest to manipulate channel by channel in code. HSL is useful for generating variations of a base colour, such as lighter or darker states for hover and active UI elements, because adjusting lightness alone tends to look correct. OKLCH is the newest option and is worth adopting for generated colour scales where perceptual evenness matters more than backward compatibility with older browsers.

Doing the conversion locally

None of this arithmetic requires sending a colour value anywhere, and a converter that runs in the browser gives an instant result with no network round trip. The colour tools in /dev-tools cover hex, RGB, HSL and the newer CSS colour functions in one place, which is useful when you are moving a colour between a design tool, a CSS file and a contrast checker in the same session.

Worked example: converting #FF6600 to HSL by hand

Normalise each channel to a 0 to 1 range: R is 255/255 = 1, G is 102/255 ≈ 0.4, B is 0/255 = 0. The maximum is 1, the minimum is 0, so lightness is (max + min) / 2 = 0.5. Since max does not equal min, saturation is not zero; because lightness is exactly 0.5, saturation simplifies to (max - min) / (2 - max - min), which works out to 1, or 100%.

Hue depends on which channel is the maximum. Since red is the maximum here, hue is 60 times ((G - B) / (max - min) mod 6), which is 60 times (0.4 / 1) = 24 degrees. Rounding for display gives hsl(24, 100%, 50%), a strong orange, which matches the visual result of #FF6600 as expected.

Edge cases in colour conversion

Pure grey colours, where red, green and blue are all equal, have no defined hue mathematically, since the formula divides by (max - min), which is zero. Implementations conventionally set hue to 0 in this case, which is a safe default but worth knowing about if you are debugging why every shade of grey reports the same hue value.

Very low alpha values close to but not exactly zero can round to a fully transparent 00 in an eight-digit hex conversion if the rounding step truncates rather than rounds, silently discarding a colour that was meant to be barely visible rather than fully invisible. Similarly, converting OKLCH values outside the sRGB gamut back to hex or RGB requires gamut mapping, since some OKLCH colours simply have no exact sRGB equivalent and need to be clamped to the nearest representable colour.

  • Grey colours (R = G = B) have undefined hue; implementations default it to 0
  • Truncating rather than rounding alpha can turn near-transparent into fully transparent
  • Some OKLCH colours fall outside the sRGB gamut and need clamping when converted back
  • Case does not matter in hex digits, but some tools expect lowercase output consistently

Debugging checklist for colour conversions

If a converted colour looks visibly wrong, first check whether the channel order was swapped, since red and blue being transposed is an extremely common bug and produces a colour that is recognisably off rather than obviously broken. Second, check whether the input hex string still has a leading # character left in it by mistake, since parseInt on a string starting with # returns NaN rather than throwing, which can silently propagate as a black or zero value further down the pipeline.

Third, if a round trip through HSL and back to hex produces a colour one or two units off from the original, that is expected rounding error rather than a bug, and the fix is to store the original hex value rather than regenerating it from a derived form.

  • Check for a red and blue channel swap first; it is the most common mistake
  • Strip a leading # before calling parseInt, since it silently returns NaN otherwise
  • Small rounding drift after an HSL round trip is expected, not a bug
  • Confirm whether alpha is expected as 0 to 1, 0 to 100, or 00 to FF; all three appear across CSS syntaxes

FAQ

Is oklch() supported everywhere yet? Support has improved significantly in modern browsers, but always provide an rgb() or hex fallback for anything that must render correctly in older browsers or email clients, since email rendering engines lag well behind browser CSS support.

Why does the same hex value look different on two screens? Hex and RGB values are device-dependent unless a colour profile is specified; two monitors with different colour calibration will render the identical numeric value slightly differently, which is a display characteristic rather than a conversion error.

Do I need to worry about contrast ratio for large decorative text? WCAG AA still expects 3:1 for large text (18pt or 14pt bold and larger), which is lower than the 4.5:1 required for normal body text, but it is not zero, and purely decorative text that conveys no information is generally exempt.

Questions about the tools in this guide

Short answers about the hubs this article touches, each linking straight to the tool.

UI Assets

Open hub

Dev Tools

Open hub