HEX, RGB, HSL, HSV: Colour Formats Every Developer Should Know

26 March, 2026 Web

Colour formats are something every frontend developer uses daily but rarely thinks about deeply. You copy a hex value from Figma, drop it into CSS, and move on. But the moment you need to generate colour variations programmatically, handle transparency in a compositing pipeline, or target wide-gamut displays, the differences between formats matter a lot. This article covers each format from the ground up - no black boxes.

HEX: RGB in Disguise

A hex colour like #FF5733 is just three pairs of hexadecimal digits, each representing one byte (0-255) of an RGB channel:

#FF5733
  ^^     Red   = 0xFF = 255
    ^^   Green = 0x57 = 87
      ^^ Blue  = 0x33 = 51

Hex is positional notation in base 16. The digits are 0-9 and a-f, where a = 10, b = 11, and so on up to f = 15. Each pair encodes a value from 0x00 (0) to 0xFF (255). A two-digit hex number is simply high * 16 + low, so 0x57 = 5 * 16 + 7 = 87.

Shorthand form: When both digits in each pair are identical, you can collapse to three digits. #FF5533 cannot be shortened, but #FF5533 → no. #FFAA33#FA3. CSS expands this back to #FFAA33 by duplicating each digit. This is a pure syntactic shortcut - not a different format.

8-digit HEX with alpha: CSS Color Level 4 added a fourth byte for the alpha channel: #FF5733CC. The last pair CC = 0xCC = 204, which as opacity is 204 / 255 ≈ 0.8 (80% opaque). Equivalently, you can use a 4-digit short form: #F53C.

HEX is ubiquitous because it is compact, copy-pasteable, and universally supported. The downside is that it is completely opaque to human intuition - you cannot look at #FF5733 and know whether it is warm or cool, saturated or muted.

RGB and RGBA: The Hardware Reality

RGB maps directly to how displays work - a grid of pixels, each with red, green, and blue sub-pixels emitting light at varying intensities. The standard rgb() CSS function takes three channels, each from 0 to 255:

color: rgb(255, 87, 51);

Why 0-255? Each channel uses 8 bits. 2^8 = 256 possible values (0 through 255). Three channels at 8 bits each give 24-bit colour, also called "true colour." The total gamut is 256 * 256 * 256 = 16,777,216 distinct colours.

Modern CSS syntax: CSS Color Level 4 allows space-separated values and a slash for alpha, dropping the need for rgba() as a separate function:

/* Legacy */
rgba(255, 87, 51, 0.8)

/* Modern - equivalent */
rgb(255 87 51 / 0.8)
rgb(255 87 51 / 80%)

/* Also valid - percentages for channels */
rgb(100% 34% 20% / 0.8)

The modern syntax is supported in all current browsers. The old comma syntax still works and will for the foreseeable future.

RGB is the right format when you need to pass colour data to graphics APIs, image processing libraries, or hardware. In JavaScript, the Canvas API works with pixel buffers where each pixel is four bytes: R, G, B, A. In PHP or Python, image manipulation libraries (GD, Pillow) accept and return RGB tuples.

HSL: A Human-Centred Model

HSL - Hue, Saturation, Lightness - was designed to be more intuitive than RGB. The same orange from above becomes:

hsl(11, 100%, 60%)

The three components:

  • Hue (0-360): An angle on the colour wheel. 0 and 360 are red. 120 is green. 240 is blue. Yellow is 60, cyan is 180, magenta is 300.
  • Saturation (0-100%): How vivid the colour is. 0% is a shade of grey; 100% is fully saturated.
  • Lightness (0-100%): 0% is always black. 100% is always white. 50% is the "pure" colour at full saturation.

The killer feature for developers is that you can generate colour variants without touching hue at all:

/* Base color */
--accent: hsl(11, 100%, 60%);

/* Darker version for hover */
--accent-dark: hsl(11, 100%, 45%);

/* Muted version for backgrounds */
--accent-muted: hsl(11, 30%, 92%);

/* Same hue, completely different use */
--accent-text: hsl(11, 60%, 30%);

This would be extremely awkward to express with RGB. HSL is the format of choice for design systems and CSS custom property themes.

CSS also supports hsla() (legacy) and the modern slash syntax:

hsl(11 100% 60% / 0.8)

HSV / HSB: What Photoshop Actually Uses

HSV (Hue, Saturation, Value) - also called HSB (Hue, Saturation, Brightness) - looks similar to HSL but has a meaningfully different model. Hue is the same 0-360 degree wheel. Saturation means the same thing. The difference is in the third channel.

HSL's Lightness is symmetric: pure black at 0%, pure white at 100%, and the most vivid version of a hue at exactly 50%. Think of a bicone (double cone) - black at the bottom vertex, white at the top, and the hue band wrapping around the widest middle point.

HSV's Value works differently: 0% is always black, 100% is the fully vivid colour (not white). To get to white you increase saturation downward (toward 0%) while keeping value high. Think of a single cone - black at the tip, the full-saturation hue colours at the base ring, and white at the centre of the base.

The practical consequence: when you drag a colour in Photoshop or Figma's colour picker, you are working in HSV space. The square picker has saturation on the X axis and value on the Y axis for a fixed hue. The bottom-left is black, the top-left is white, the top-right is the pure hue, and the bottom-right is also black (zero value, full saturation).

CSS does not have a native hsv() function. If you are building a colour picker component or need to interface with design tool APIs, you will convert between HSV and RGB in code.

Conversion Formulas

RGB to HEX

Trivial - convert each channel to two hex digits:

function rgbToHex(r, g, b) {
    return '#' + [r, g, b]
        .map(v => v.toString(16).padStart(2, '0'))
        .join('');
}

rgbToHex(255, 87, 51); // '#ff5733'

RGB to HSL

Given r, g, b normalised to 0-1 range (divide each by 255):

r' = r / 255,  g' = g / 255,  b' = b / 255

cmax = max(r', g', b')
cmin = min(r', g', b')
delta = cmax - cmin

Lightness:
  L = (cmax + cmin) / 2

Saturation:
  S = 0                         if delta == 0
  S = delta / (1 - |2L - 1|)   otherwise

Hue:
  H = 0                              if delta == 0
  H = 60 * ((g' - b') / delta % 6)  if cmax == r'
  H = 60 * ((b' - r') / delta + 2)  if cmax == g'
  H = 60 * ((r' - g') / delta + 4)  if cmax == b'

HSL to RGB

Given h (0-360), s and l (0-1):

C = (1 - |2L - 1|) * S      (chroma)
X = C * (1 - |H/60 mod 2 - 1|)
m = L - C/2

(R', G', B') depending on H:
  0   <= H < 60:   (C, X, 0)
  60  <= H < 120:  (X, C, 0)
  120 <= H < 180:  (0, C, X)
  180 <= H < 240:  (0, X, C)
  240 <= H < 300:  (X, 0, C)
  300 <= H < 360:  (C, 0, X)

R = (R' + m) * 255
G = (G' + m) * 255
B = (B' + m) * 255

These are the same formulas your browser runs under the hood. No libraries required.

You can verify any conversion with the colour converter directly in your browser.

Alpha Channels: Opacity vs Premultiplied Alpha

Adding an alpha channel is straightforward in CSS - a fourth value between 0 (fully transparent) and 1 (fully opaque). But there are two ways to store alpha in image data, and they behave differently.

Straight (unassociated) alpha stores RGB and alpha independently. A pixel that is 50% transparent red is stored as (255, 0, 0, 128). The RGB values represent the "true" colour before transparency is applied.

Premultiplied alpha multiplies the RGB values by the alpha before storing. That same 50% transparent red becomes (128, 0, 0, 128). The RGB channels already encode the contribution of this pixel to the final composited image.

Why premultiply? Because the standard Porter-Duff compositing equations - the math behind layer blending in Photoshop, CSS mix-blend-mode, and every graphics compositor - work natively with premultiplied values:

result = src_rgb + dst_rgb * (1 - src_alpha)

This formula is correct for premultiplied inputs and produces premultiplied output. With straight alpha you need extra multiply and divide steps, and near-transparent pixels with saturated colours cause subtle halo artifacts (a bright colour bleeds into adjacent transparent pixels during filtering and scaling).

The Web platform mostly hides this from you - canvas, CSS, and SVG use premultiplied alpha internally. But if you are processing raw pixel data, writing shaders, or integrating with a video pipeline, knowing which convention your API uses prevents compositing bugs.

Colour Spaces: sRGB vs Linear RGB

Here is a fact that surprises many developers: #808080 is not 50% brightness. It looks like 50% grey, but its actual luminance is about 21.4% of white.

This is because the sRGB colour space - the standard for monitors, CSS, and virtually all digital images - uses gamma encoding. The raw values are put through a transfer function (approximately gamma 2.2) that brightens the mid-tones. This was originally designed to compensate for the non-linear response of CRT monitors and to pack more perceptually distinct steps into the limited precision of 8-bit values.

Linear RGB has no gamma encoding. A value of 0.5 in linear RGB has exactly 50% of the luminance of 1.0. Linear RGB is required for physically correct light calculations - blending, lighting in 3D, blurring, and any math that assumes linearity.

The practical consequence shows up in image processing. If you average two colours by blending their sRGB values at the midpoint, the result is slightly too dark. This is why naive image scaling in sRGB looks different from a gamma-correct downscale. CSS transitions and gradients have historically suffered from this - a gradient from red to green passes through a dark muddy middle rather than a bright yellow.

CSS Colour Level 4 addresses this with the color() function and explicit colour space parameters:

/* sRGB - standard, default everywhere */
color: color(srgb 1 0.34 0.20);

/* Linear sRGB - for physically correct blending */
color: color(srgb-linear 1 0.34 0.20);

/* Display P3 - wide gamut, available on modern Apple displays and others */
color: color(display-p3 1 0.34 0.20);

Wide-gamut displays (P3, Rec. 2020) can display colours outside the sRGB triangle. A saturated red on a P3 display is visibly more vivid than the most saturated red you can express in sRGB. CSS can target these colours with color(display-p3 ...), and browsers on wide-gamut hardware will render them correctly. On sRGB hardware, the browser clips or maps to the nearest sRGB colour.

/* Progressive enhancement pattern for P3 */
color: rgb(255, 0, 0);
color: color(display-p3 1 0 0);

Practical Guide: When to Use Each Format

In CSS:

  • HEX - static colours, design tokens from Figma, anything you are typing once and not calculating. Concise and universally understood.
  • HSL - dynamic themes, CSS custom properties, any colour you need to programmatically lighten, darken, or desaturate. calc() works well with HSL: hsl(var(--hue) var(--saturation) calc(var(--lightness) + 15%)).
  • RGBA / rgb() with alpha - when you need transparency and already have an RGB value from somewhere else. Modern rgb() handles alpha, so rgba() is legacy.
  • color() - wide-gamut targets, colour-critical UI (photo editing tools, design applications), or when you need to be explicit about colour space in a CSS pipeline.

In code:

  • RGB arrays/tuples - the natural format for image processing (canvas pixel buffers, Pillow, Sharp, OpenCV). Direct access to channels.
  • HSL/HSV - colour manipulation: adjusting brightness, generating palettes, rotating hues, checking contrast ratios. Far easier to work with than RGB arithmetic.
  • HEX strings - serialisation, storage, interoperability. Easy to parse and write; meaningless for arithmetic.

When building a colour picker or palette generator, the typical pattern is: store as HEX or RGB for persistence, convert to HSL or HSV for manipulation, convert back to HEX/RGB for output. You can handle all of this client-side - use the colour converter to verify your conversions interactively.

Which Format for What

HEX, RGB, HSL, and HSV are different representations of the same underlying data - three coordinates describing a point in colour space. HEX is compact and ubiquitous but opaque. RGB maps directly to hardware. HSL is optimised for human-readable manipulation. HSV matches how designers think about colours in pickers and tools.

The subtler issues - premultiplied alpha, gamma encoding, and wide-gamut colour spaces - only surface when you work with graphics pipelines, image processing, or colour-sensitive UI. Knowing they exist and roughly how they work is enough to avoid the most common bugs and reach for the right documentation when you encounter them.

More Articles

CSV vs JSON for Data Exchange: When Each Format Wins

A practical comparison of CSV and JSON for APIs, data pipelines, and file exports. Covers structure, parsing, streaming, schema enforcement, size, tooling, and clear guidelines for choosing the right format.

15 April, 2026

SEO for AI Search: How to Optimise for ChatGPT, Perplexity, and Google AI Overviews

How AI-powered search engines discover, evaluate, and cite web content. Practical strategies for optimising your pages for ChatGPT Browse, Perplexity, Google AI Overviews, and other AI answer engines.

14 April, 2026

Image to Base64 Data URIs: When to Inline and When Not To

A practical guide to embedding images as Base64 data URIs. Covers the data URI format, size overhead, performance trade-offs, browser caching, Content Security Policy, and clear rules for when inlining helps vs hurts.

10 April, 2026