Gamut Mapping: Why Not Every Color Can Be Displayed
sRGB and Display P3 gamut boundaries, out-of-gamut colors, and Helmlab's hue-preserving chroma reduction gamut mapping approach.
You pick a beautiful turquoise in a design tool. You copy the hex code into your CSS. But the color on screen looks different -- slightly duller, maybe shifted toward green. This is not a bug. This is a gamut boundary.
Every screen has a finite set of colors it can reproduce, and that set has hard limits. Not every color that exists in a theoretical color space can be shown on a display. In this post, we explain what a gamut is, why some colors cannot be displayed, and how Helmlab solves this problem.
What Is a Gamut?
A gamut is the complete set of colors that a device can produce or display. Think of it like a box of paints: if your box has 12 tubes, the gamut is every color you can mix from those 12 tubes. But a color that requires a pigment you do not have -- say, a fluorescent green -- is impossible no matter how you mix.
Computer screens work the same way. Every monitor has three primary light sources: red, green, and blue (RGB). By varying the intensity of these three lights, the display creates every pixel color you see. But the spectral properties of those three primaries define a hard boundary on which colors the screen can reproduce.
sRGB, Display P3, and Theoretical Lab
In the color world, there are three fundamental gamut levels:
sRGB: The Standard Gamut
Defined in 1996 by HP and Microsoft, sRGB is the dominant standard on the web. Most monitors, browsers, and operating systems interpret colors as sRGB by default. Hex codes (#FF5733), CSS rgb() functions, JPEG photographs -- all sRGB by default.
But sRGB covers only about one-third of the colors the human eye can see. It is particularly limited in saturated cyan, deep teal, and vivid green regions.
Display P3: Wide Gamut
Apple has used Display P3 across all its devices since 2016. It offers approximately 25% more colors than sRGB, with significant expansion in the red and green regions. iPhone, iPad, MacBook Pro screens, and many modern Android devices can display all or most of this gamut.
In CSS, Display P3 colors are defined like this:
/* sRGB: standard */
color: rgb(255, 87, 51);
/* Display P3: wider gamut */
color: color(display-p3 1.0 0.34 0.20);CIE Lab / Helmlab Lab: Theoretical Space
Perceptual color spaces like CIE Lab and Helmlab Lab are not bound to any device. These spaces model the full range of human vision -- they can assign coordinates to every color the eye can see. But most of those coordinates cannot be shown on any screen.
The diagram summarizes the situation: some Lab colors fall inside sRGB, some can only be displayed with Display P3, and some cannot be shown on any current screen.
When Does Out-of-Gamut Happen?
Going out of gamut in everyday work is more common than you might think:
- Palette generation -- When you create an evenly-spaced color scale in Lab space, some steps may exceed sRGB boundaries. The light and dark extremes of saturated hues are especially prone to this.
- Color interpolation -- When you interpolate between two sRGB colors in Lab, some intermediate colors may land outside sRGB. Blue-to-green transitions are particularly susceptible.
- Wide-gamut images -- A photo taken on an iPhone in DCI-P3 loses saturation when displayed on an sRGB monitor. How that loss is managed depends on the gamut mapping strategy.
- Design tools -- Working in Display P3 mode in Figma, then exporting to CSS as sRGB, changes some colors silently.
Why Naive RGB Clamping Is Bad
You have an out-of-gamut color. The simplest fix: clip each RGB channel independently to the 0-1 range. But this approach creates a serious problem: hue shift.
Why? Because when RGB channels are clipped independently, their ratios change. And hue is defined precisely by those ratios.
An example: you have a saturated cyan in Lab space. When converting to sRGB, the blue channel exceeds 1.0. Naive clamp clips it to 1.0 but leaves the red and green channels untouched. The result: blue decreased but red and green stayed the same -- the color is no longer cyan, it has shifted toward green. The difference is clearly visible.
This problem is especially destructive in palette generation. If 3 or 4 steps in an 11-step color scale go out of gamut and each one shifts in a different direction, your scale is no longer perceptually uniform.
Helmlab's Approach: Chroma Reduction via Binary Search
Helmlab uses a fundamentally different strategy for gamut mapping: binary search chroma reduction. The principle is simple:
- Decompose the color into Lab coordinates: L (lightness), C (chroma/saturation), H (hue)
- Keep L and H fixed -- these are the color's "identity"
- Gradually reduce C (via binary search) -- until the color falls within the gamut boundary
- Result: same hue, same lightness, just less saturated
The key advantage of this approach: hue never changes. A turquoise remains turquoise after gamut mapping -- just slightly less saturated. A red stays red. The color's identity is preserved.
How Does the Binary Search Work?
Helmlab uses binary search to find the optimal chroma value. The algorithm works as follows:
- Start: C_min = 0 (definitely in gamut), C_max = current chroma (out of gamut)
- Test the midpoint: C_mid = (C_min + C_max) / 2
- If C_mid is in gamut: C_min = C_mid (try higher chroma)
- If C_mid is out of gamut: C_max = C_mid (try lower chroma)
- Repeat up to 50 iterations or until precision reaches 0.0001
Fifty iterations resolve the chroma value to 2^50 precision -- essentially absolute accuracy. And each iteration performs only one Lab -> XYZ -> linear RGB conversion, which is extremely fast.
Gamut Mapping in Helmlab: toSRGB() and toHex()
In the Helmlab npm package, gamut mapping is automatic. When you call toSRGB() or toHex(), if the color is outside the sRGB gamut, chroma reduction is applied automatically:
import { Helmlab } from 'helmlab';
const hl = new Helmlab();
// Define a saturated color in Lab
// (this color may be outside the sRGB gamut)
const lab = hl.fromSRGB(0.0, 0.85, 1.0);
console.log('Lab:', lab);
// toHex() automatically gamut maps
const hex = hl.toHex(lab);
console.log('Hex:', hex);
// Check gamut membership
console.log('In sRGB?', hl.isInSRGB(lab));
console.log('In P3?', hl.isInP3(lab));You can also check gamut membership manually:
import { Helmlab } from 'helmlab';
const hl = new Helmlab();
// Start from any hex code
const lab = hl.fromHex('#00d9ff');
console.log('In sRGB gamut:', hl.isInSRGB(lab));
console.log('In Display P3 gamut:', hl.isInP3(lab));
// toSRGB applies gamut mapping
const [r, g, b] = hl.toSRGB(lab);
console.log('sRGB:', r.toFixed(3), g.toFixed(3), b.toFixed(3));
// toDisplayP3 maps to the wider gamut
const [r3, g3, b3] = hl.toDisplayP3(lab);
console.log('P3:', r3.toFixed(3), g3.toFixed(3), b3.toFixed(3));Gamut Mapping in Palette Generation
Gamut mapping matters most in palette generation. Helmlab's semanticScale() function applies gamut mapping at every step:
import { Helmlab } from 'helmlab';
const hl = new Helmlab();
// Generate a palette for a saturated cyan
const palette = hl.semanticScale('#00BCD4');
// Every step is automatically gamut-mapped
// Dark steps (50-200): likely within gamut
// Light steps (800-950): near gamut boundary, chroma reduced
// Result: hue stays consistent, only saturation adjusts
console.log(palette);
// { 50: '#f0fdfd', 100: '#ccf5f6', ... 900: '#003d44', 950: '#001f22' }Why does this matter? Without gamut mapping, light steps in the scale would shift to different hues due to naive clamping, creating inconsistencies in the palette. Helmlab handles this automatically.
Working with Display P3
If your target is Display P3 devices, Helmlab offers the same approach for the wider gamut:
import { Helmlab } from 'helmlab';
const hl = new Helmlab();
const lab = hl.fromHex('#00BCD4');
// See the difference between sRGB and P3
console.log('In sRGB:', hl.isInSRGB(lab));
console.log('In P3:', hl.isInP3(lab));
// Since P3 offers a wider gamut,
// some colors that need gamut mapping in sRGB
// can be displayed directly in P3
const srgb = hl.toSRGB(lab);
const p3 = hl.toDisplayP3(lab);
console.log('sRGB:', srgb.map(v => v.toFixed(3)));
console.log('P3: ', p3.map(v => v.toFixed(3)));On P3-capable displays, you can serve wide-gamut colors using the @media (color-gamut: p3) CSS query. Helmlab provides the same quality gamut mapping for both gamuts.
Why Only Reduce Chroma?
Other gamut mapping strategies exist. Some approaches also shift lightness, others rotate hue to find a more saturated nearby color. Why does Helmlab reduce only chroma?
- Hue preservation -- Hue is the most important identifying property of a color. Red should stay red, blue should stay blue. Changing hue changes the color's identity.
- Lightness consistency -- Changing lightness creates cascading problems, especially in palette generation. It disrupts the evenly-spaced L steps in a scale.
- Perceptual accuracy -- The human eye is less sensitive to saturation loss than to hue or lightness shifts. A small reduction in saturation is nearly invisible, while a small hue shift is immediately noticeable.
- Invertibility -- Chroma reduction, with H and L held fixed, is a one-dimensional search. This is computationally efficient and produces predictable results.
Conclusion
Not every color can be displayed on every screen. sRGB, Display P3, and theoretical Lab spaces have gamuts of different sizes. When generating color palettes, interpolating between colors, or bringing wide-gamut content to sRGB displays, going out of gamut is inevitable.
Naive RGB clamping clips channels independently, causing hue shifts. Helmlab solves this fundamentally differently: it keeps hue and lightness fixed, using binary search to reduce only chroma (saturation). The result is gamut mapping that preserves color identity, is predictable, and is perceptually correct.
All of this happens automatically inside toSRGB() and toHex() calls:
npm install helmlabSource code: GitHub | Package: npm
This is post 8 of 16 in the Helmlab series. Next up: Neutral Correction -- how Helmlab structurally guarantees a correct achromatic axis.