</>
Back to Blog
Visual 2026-05-25 11 min read

Color, Properly Explained

Color on a screen is three numbers per pixel that the GPU sends to the panel. Color in your code is a string. Mapping the string to the numbers correctly turns out to be the entire story β€” and the story has more historical baggage than almost any other corner of UI engineering. CSS shipped a fix for it (`oklch()`) in 2023, browsers all support it, and most stylesheets still aren't using it.

ColorsRGBHSLOKLCHGammaWCAGColor Blindness

The reason color formats are confusing isn't because there are many of them β€” there are many of them for different jobs, and the confusion is using the wrong one for the job.

This post is the field guide.

What's actually happening on the screen

A screen pixel is three subpixels β€” one red, one green, one blue. The OS, via the GPU, sends three numbers (one per channel) to the panel. The panel converts those numbers into light intensities. Three numbers in, one perceived color out.

The numbers are called the RGB triple, and the format the GPU expects is sRGB unless something downstream is doing color management. sRGB is a specific color space: a definition of which red, green, and blue the panel will produce, plus a gamma curve that defines how the numerical value maps to light intensity.

Everything in the rest of this post is, mechanically, about how to encode an sRGB triple compactly enough that humans can write it.

Hex and RGB are the same thing

#ff0000, #FF0000, rgb(255, 0, 0), rgb(100%, 0%, 0%) β€” all the same color. The hex form is just RGB written as base 16, two digits per channel.

Three-digit hex (#f00) is shorthand: each digit is repeated to two digits, so #f00 = #ff0000, #abc = #aabbcc. Useful when each channel happens to use the same upper and lower nibble.

Four-digit hex (#f00a) and eight-digit hex (#ff0000aa) include alpha (transparency) as the last component. CSS adopted these around 2018; they parse cleanly in modern browsers. The #rrggbbaa order is RGBA β€” alpha last β€” even though rgba(255, 0, 0, 0.667) puts alpha last too. Just don't reverse them.

Alpha is 0 (transparent) to 255 (opaque) in hex, or 0 to 1 in rgba(). CSS rgba(255, 0, 0, 0.5) and #ff000080 are the same color. The 50%-of-255 rounds to 128 = 0x80.

The numerical RGB values themselves are non-linear β€” see "gamma" below. This matters for color math; mostly doesn't matter for picking colors.

HSL and HSV: the most common lie

Hue / Saturation / Lightness (HSL) and Hue / Saturation / Value (HSV/HSB) were designed to be more intuitive than RGB. The pitch: pick a hue (0–360Β° on the color wheel), pick how saturated you want it, pick how bright you want it. The trouble: HSL's "lightness" doesn't correspond to perceived lightness.

Demonstrate this to yourself. In CSS:

.a { background: hsl(60, 100%, 50%); }   /* yellow */
.b { background: hsl(240, 100%, 50%); }  /* blue */

Both have the same lightness: 50%. They are not equally bright. Yellow at 50% lightness is glaringly bright; blue at 50% lightness is dark and saturated. The reason: HSL's "lightness" is a mathematical mid-point in RGB space, not in human visual perception. Yellow is intrinsically a brighter color than blue, and HSL doesn't account for it.

This is why every "I built a color palette generator that picks shades by adjusting HSL" project produces palettes that look uneven. The lightness slider doesn't track perceived lightness, so equal-lightness-step swatches look unequally bright.

HSV (used in Photoshop, GIMP, and many color pickers) has the same problem with value.

Use HSL/HSV when you want a quick way to construct a color from human-readable parts. Don't use them when you need perceptual color math (interpolating two colors, generating evenly-spaced palettes, computing contrast, doing "this color but slightly darker").

Gamma: the curve that makes pixels look right

The numbers in rgb(0, 0, 0) to rgb(255, 255, 255) aren't a linear scale of light. The sRGB color space defines a specific gamma curve β€” roughly output = input^2.2 β€” that's applied somewhere between your code and the panel.

Why: human vision is logarithmic. We're more sensitive to changes in dark tones than in bright tones. If you allocated 256 brightness levels linearly, the dark half would have visible banding and the bright half would have wasted resolution. sRGB's gamma curve allocates more codes to the dark range, where eyes notice.

The consequence: rgb(127, 127, 127) is not 50% as bright as rgb(255, 255, 255). It's about 22% as bright. The "midpoint" of sRGB is around rgb(186, 186, 186).

Why this bites: averaging two RGB triples does not give a perceptually-average color. If you blend red and green by averaging RGB values, you get a muddy dark olive, not the vivid yellow you'd expect. To do real color math you have to convert sRGB to linear RGB first, do the math, then convert back.

Most blends in UI code skip this step. The result is the slightly-off feel of poorly-implemented gradients and dark-mode color systems. Browsers fixed this in 2022 with color-mix() and interpolate-in-oklch syntax β€” when you opt into a perceptual color space, the math is right.

OKLCH: the modern answer

CSS Color Module 4 added oklch() in 2022, with browser support universal by 2024. OKLCH is a perceptual color space:

  • L (lightness): 0 to 1, perceptually uniform. 0.5 means "half as bright as I can see."
  • C (chroma): 0 to ~0.4, the colorfulness. 0 is gray; higher is more saturated.
  • H (hue): 0 to 360Β°.

Two key wins over HSL:

  1. L is perceptual. Two colors at the same L are perceived as equally bright, regardless of hue. Generating a 10-step palette by stepping L from 0.1 to 0.9 produces visibly even steps.
  2. Hue rotation preserves brightness. In HSL, a hue change at constant lightness changes apparent brightness. In OKLCH, it doesn't.

Cost: the values aren't intuitive in the way HSL is. oklch(0.7 0.15 30) is harder to ballpark than hsl(30, 80%, 50%). Use a tool to translate. The win is in the math, not the writing.

The color-mix() CSS function lets you interpolate in OKLCH directly:

background: color-mix(in oklch, blue 50%, red);

That mid-point will look like a real purple, not a muddy gray. Designers have been recreating this manually for decades.

If you're building a design system in 2026, define your tokens in OKLCH. Convert to hex/RGB at the build step if you must. The downstream consequences β€” palette generation, dark-mode flipping, contrast checking β€” all become cleaner.

CMYK: the printer one

Cyan, Magenta, Yellow, Key (black). Subtractive color, used in print. Each channel is 0% to 100% ink coverage; the four printing plates ink the page in that order.

Two things to understand:

  1. CMYK's gamut is smaller than sRGB. The vivid blues and bright greens you can see on a screen don't exist in print. Going from sRGB to CMYK is a lossy mapping; the conversion has to choose how to handle out-of-gamut colors (clip vs. desaturate).
  2. CMYK is device-specific. "CMYK" without a profile (e.g., FOGRA39 for European print, GRACoL for US) is meaningless. Two printers given the same CMYK numbers will produce different results.

For screen-only work, ignore CMYK. For print, use the profile your printer asks for, soft-proof in Photoshop (or equivalent), and expect the colors to come out duller than on screen no matter what. There's no software fix for "this RGB doesn't exist in CMYK."

WCAG contrast: the rule that's about to change

The Web Content Accessibility Guidelines define a contrast ratio formula: roughly, (L1 + 0.05) / (L2 + 0.05), where L1 is the luminance of the lighter color and L2 of the darker. The formula uses the relative luminance of an sRGB color (a weighted sum of linear-RGB channels) and is what browsers' built-in contrast checkers compute.

Thresholds:

  • 4.5:1 for normal text (WCAG AA).
  • 3:1 for large text or UI components (WCAG AA).
  • 7:1 for AAA-level normal text.

The formula is widely-known to be approximate. It's well-calibrated for black-on-white and similar high-contrast pairs, less so for subtle dark-mode pairs, less so still for color-on-color text. The next major revision (WCAG 3.0, in active development as of 2026) replaces it with APCA (Accessible Perceptual Contrast Algorithm), which uses a perceptual model and produces different numbers β€” sometimes wildly different β€” for the same color pairs.

For shipping work in 2026: meet WCAG 2.x AA thresholds because that's what audits and procurement use. If you have the bandwidth, also check APCA, especially for dark-mode pairs where WCAG 2 is most generous and most wrong.

Color blindness

Roughly 8% of men and 0.5% of women have some form of color vision deficiency. The most common is red-green confusion (deuteranomaly / protanomaly). The takeaway for UI: color alone should never be the only signal. A red error and a green success should also differ in icon, label, or position. A graph with eight series in eight colors should also use line styles or markers.

Tools like Coblis and the browser DevTools color-blindness simulators are quick to use. They're a rough proxy β€” real color vision deficiency varies, and severe cases see colors very differently β€” but they catch the common failure modes.

Specific rules that help:

  • Don't pair red and green for opposing meanings without other distinguishing features.
  • Use blue/orange as a high-contrast color pair if you only have two; both are visible to most CVD types.
  • Vary lightness, not just hue. Lightness is robustly perceived; hue isn't.

Common pitfalls

  • Comparing RGB triples to compute "similar" colors. Use a perceptual space (OKLCH, Lab) instead.
  • Linear interpolation in sRGB. Especially for gradients. Use OKLCH or linear RGB.
  • Picking palettes by HSL lightness steps. Use OKLCH L.
  • Trusting WCAG 2 contrast for dark-mode designs. APCA is closer to truth.
  • CMYK without a profile. Pick the right one for your printer.
  • Three-channel hex when you wanted alpha. #fff is white, not transparent.
  • rgba(255, 0, 0, 0.5) over a dark background. The result looks dark red, not pink β€” alpha blends to the background, not to a fixed midtone.
  • CSS variables holding hex strings instead of decomposed channels. If you want to use color: rgb(var(--brand) / 0.5), store the channels as --brand: 220 80 100;, not as --brand: #dc5064;.
  • Browsers using different sRGB primaries depending on the OS color profile. Wide-gamut monitors will display CSS sRGB colors more vividly than narrow-gamut ones unless the page opts into a specific color space (color: color(display-p3 ...) for wide-gamut).
  • Sampling colors from screenshots without checking the screenshot's color profile. macOS screenshots in 2026 are often P3, not sRGB; pasted into a sRGB workflow they look slightly different.

When to use which format

Use case Format
Picking a one-off color in CSS hex (#dc5064)
Color tokens in a design system OKLCH (oklch(0.7 0.15 30))
Generating a stepped palette OKLCH (vary L)
Interpolating two colors OKLCH or linear RGB
Computing contrast for accessibility sRGB β†’ relative luminance β†’ WCAG ratio
Print CMYK with a profile
Working with image data programmatically linear RGB
Hand-tuning by eye HSL is fine for "make it slightly lighter"
Wide-gamut on macOS / iOS color(display-p3 ...)

The narrative arc of the last decade has been: stop thinking of color as RGB triples, start thinking of color as a perceptual coordinate that you express in whatever format the consumer expects. CSS finally gave you the tools to do that natively in 2022. The cost of switching to OKLCH is one line in a build pipeline; the gain is design tokens that compose, gradients that look right, and palettes that don't fight you.

Convert between every color space

The color tool on this site converts between hex, RGB, HSL, HSV, CMYK, and OKLCH locally, with a live preview and contrast warnings. Useful for cross-checking that your design tokens are consistent across formats. Nothing leaves your browser.

Open the color tool

Related guides

Keep the session useful with adjacent reading instead of exiting after one article.

View all guides

Cookie Consent

We use cookies to enhance your experience and show relevant ads. You can customize your preferences.