HSL vs HSV Color Models

8 min readUpdated 2026-06-25

Here's the thing about HSL and HSV: they both describe color with hue, saturation, and a third axis — but they define "saturation" and that third axis differently enough to confuse anyone switching between tools. Photoshop uses HSB (same as HSV). CSS uses HSL. Figma's color picker shows HSB but exports HSL. If you've ever picked a "50% saturated" color in a design tool and gotten a completely different result in CSS, this mismatch is why.

HSL stands for Hue, Saturation, Lightness. HSV stands for Hue, Saturation, Value (also called HSB — Brightness). They share the same hue wheel but disagree on everything else. Understanding when to use which model saves you from the eternal "why does my CSS color look washed out compared to the mockup" debugging session.

How It Works

The Figma-to-CSS pipeline breaks here

A designer picks a vibrant brand blue in Figma's HSB picker: H=220, S=85%, B=90%. That looks punchy on screen. The developer reads the Figma export, sees HSL values, and writes `hsl(220, 85%, 90%)` — which renders as a pale sky blue, nothing like the mockup. The disconnect: 85% saturation means different things in each model. In HSV/HSB, S=85% means "mostly pure color with little white mixed in." In HSL, S=85% at L=90% means "very light tint with a hint of blue." The fix is converting properly, not eyeballing.

How the models actually differ

| Property | HSL | HSV (HSB) | |---|---|---| | Third axis | Lightness (0% = black, 100% = white, 50% = pure color) | Value/Brightness (0% = black, 100% = fully bright) | | Saturation at max | Full color at S=100%, L=50% | Full color at S=100%, V=100% | | White | S=0%, L=100% | S=0%, V=100% | | Black | Any S, L=0% | Any S, V=0% | | Pure red | hsl(0, 100%, 50%) | hsv(0, 100%, 100%) | | Shape metaphor | Double cone (bicone) | Single cone or cylinder |

Adobe tools default to HSB for a reason

Photoshop, Illustrator, and After Effects all use HSB in their native color pickers. The reasoning: artists think in terms of "how bright is this color" and "how much white have I mixed in." Value (brightness) maps more intuitively to paint mixing. You start with a vivid hue and either darken it (reduce V) or desaturate it (reduce S). HSL's lightness axis, where 50% is the "purest" point and both 0% and 100% kill the color, feels less natural for visual picking.

CSS chose HSL because it's symmetrical

The CSS working group adopted HSL because its lightness axis is symmetrical: 0% always equals black, 100% always equals white, regardless of hue. That makes programmatic manipulation predictable. `lighten(color, 10%)` always moves toward white. `darken(color, 10%)` always moves toward black. In HSV, achieving the same symmetry requires juggling both S and V simultaneously.

When Google's Material Design team converts

Material Design defines tonal palettes using lightness steps (0, 10, 20... 100). Internally they work in HCT (their custom model), but when exporting to web tokens they use HSL. When exporting to Android native, the picker shows HSV. The same palette, two different representations. Teams that don't understand the conversion get mismatched implementations between platforms.

How It Works

The Figma-to-CSS pipeline breaks here

A designer picks a vibrant brand blue in Figma's HSB picker: H=220, S=85%, B=90%. That looks punchy on screen. The developer reads the Figma export, sees HSL values, and writes `hsl(220, 85%, 90%)` — which renders as a pale sky blue, nothing like the mockup. The disconnect: 85% saturation means different things in each model. In HSV/HSB, S=85% means "mostly pure color with little white mixed in." In HSL, S=85% at L=90% means "very light tint with a hint of blue." The fix is converting properly, not eyeballing.

How the models actually differ

| Property | HSL | HSV (HSB) | |---|---|---| | Third axis | Lightness (0% = black, 100% = white, 50% = pure color) | Value/Brightness (0% = black, 100% = fully bright) | | Saturation at max | Full color at S=100%, L=50% | Full color at S=100%, V=100% | | White | S=0%, L=100% | S=0%, V=100% | | Black | Any S, L=0% | Any S, V=0% | | Pure red | hsl(0, 100%, 50%) | hsv(0, 100%, 100%) | | Shape metaphor | Double cone (bicone) | Single cone or cylinder |

Adobe tools default to HSB for a reason

Photoshop, Illustrator, and After Effects all use HSB in their native color pickers. The reasoning: artists think in terms of "how bright is this color" and "how much white have I mixed in." Value (brightness) maps more intuitively to paint mixing. You start with a vivid hue and either darken it (reduce V) or desaturate it (reduce S). HSL's lightness axis, where 50% is the "purest" point and both 0% and 100% kill the color, feels less natural for visual picking.

CSS chose HSL because it's symmetrical

The CSS working group adopted HSL because its lightness axis is symmetrical: 0% always equals black, 100% always equals white, regardless of hue. That makes programmatic manipulation predictable. `lighten(color, 10%)` always moves toward white. `darken(color, 10%)` always moves toward black. In HSV, achieving the same symmetry requires juggling both S and V simultaneously.

When Google's Material Design team converts

Material Design defines tonal palettes using lightness steps (0, 10, 20... 100). Internally they work in HCT (their custom model), but when exporting to web tokens they use HSL. When exporting to Android native, the picker shows HSV. The same palette, two different representations. Teams that don't understand the conversion get mismatched implementations between platforms.

Comparison & Reference

The Figma-to-CSS pipeline breaks here

A designer picks a vibrant brand blue in Figma's HSB picker: H=220, S=85%, B=90%. That looks punchy on screen. The developer reads the Figma export, sees HSL values, and writes `hsl(220, 85%, 90%)` — which renders as a pale sky blue, nothing like the mockup. The disconnect: 85% saturation means different things in each model. In HSV/HSB, S=85% means "mostly pure color with little white mixed in." In HSL, S=85% at L=90% means "very light tint with a hint of blue." The fix is converting properly, not eyeballing.

How the models actually differ

| Property | HSL | HSV (HSB) | |---|---|---| | Third axis | Lightness (0% = black, 100% = white, 50% = pure color) | Value/Brightness (0% = black, 100% = fully bright) | | Saturation at max | Full color at S=100%, L=50% | Full color at S=100%, V=100% | | White | S=0%, L=100% | S=0%, V=100% | | Black | Any S, L=0% | Any S, V=0% | | Pure red | hsl(0, 100%, 50%) | hsv(0, 100%, 100%) | | Shape metaphor | Double cone (bicone) | Single cone or cylinder |

Adobe tools default to HSB for a reason

Photoshop, Illustrator, and After Effects all use HSB in their native color pickers. The reasoning: artists think in terms of "how bright is this color" and "how much white have I mixed in." Value (brightness) maps more intuitively to paint mixing. You start with a vivid hue and either darken it (reduce V) or desaturate it (reduce S). HSL's lightness axis, where 50% is the "purest" point and both 0% and 100% kill the color, feels less natural for visual picking.

CSS chose HSL because it's symmetrical

The CSS working group adopted HSL because its lightness axis is symmetrical: 0% always equals black, 100% always equals white, regardless of hue. That makes programmatic manipulation predictable. `lighten(color, 10%)` always moves toward white. `darken(color, 10%)` always moves toward black. In HSV, achieving the same symmetry requires juggling both S and V simultaneously.

When Google's Material Design team converts

Material Design defines tonal palettes using lightness steps (0, 10, 20... 100). Internally they work in HCT (their custom model), but when exporting to web tokens they use HSL. When exporting to Android native, the picker shows HSV. The same palette, two different representations. Teams that don't understand the conversion get mismatched implementations between platforms.

Real-World Examples

The Figma-to-CSS pipeline breaks here

A designer picks a vibrant brand blue in Figma's HSB picker: H=220, S=85%, B=90%. That looks punchy on screen. The developer reads the Figma export, sees HSL values, and writes `hsl(220, 85%, 90%)` — which renders as a pale sky blue, nothing like the mockup. The disconnect: 85% saturation means different things in each model. In HSV/HSB, S=85% means "mostly pure color with little white mixed in." In HSL, S=85% at L=90% means "very light tint with a hint of blue." The fix is converting properly, not eyeballing.

How the models actually differ

| Property | HSL | HSV (HSB) | |---|---|---| | Third axis | Lightness (0% = black, 100% = white, 50% = pure color) | Value/Brightness (0% = black, 100% = fully bright) | | Saturation at max | Full color at S=100%, L=50% | Full color at S=100%, V=100% | | White | S=0%, L=100% | S=0%, V=100% | | Black | Any S, L=0% | Any S, V=0% | | Pure red | hsl(0, 100%, 50%) | hsv(0, 100%, 100%) | | Shape metaphor | Double cone (bicone) | Single cone or cylinder |

Adobe tools default to HSB for a reason

Photoshop, Illustrator, and After Effects all use HSB in their native color pickers. The reasoning: artists think in terms of "how bright is this color" and "how much white have I mixed in." Value (brightness) maps more intuitively to paint mixing. You start with a vivid hue and either darken it (reduce V) or desaturate it (reduce S). HSL's lightness axis, where 50% is the "purest" point and both 0% and 100% kill the color, feels less natural for visual picking.

CSS chose HSL because it's symmetrical

The CSS working group adopted HSL because its lightness axis is symmetrical: 0% always equals black, 100% always equals white, regardless of hue. That makes programmatic manipulation predictable. `lighten(color, 10%)` always moves toward white. `darken(color, 10%)` always moves toward black. In HSV, achieving the same symmetry requires juggling both S and V simultaneously.

When Google's Material Design team converts

Material Design defines tonal palettes using lightness steps (0, 10, 20... 100). Internally they work in HCT (their custom model), but when exporting to web tokens they use HSL. When exporting to Android native, the picker shows HSV. The same palette, two different representations. Teams that don't understand the conversion get mismatched implementations between platforms.

HSV ↔ HSL conversion in TypeScript

/** Convert HSV (all 0-1) to HSL (all 0-1) */
function hsvToHsl(h: number, s: number, v: number): [number, number, number] {
  const l = v * (1 - s / 2);
  const sl = (l === 0 || l === 1) ? 0 : (v - l) / Math.min(l, 1 - l);
  return [h, sl, l];
}

/** Convert HSL (all 0-1) to HSV (all 0-1) */
function hslToHsv(h: number, s: number, l: number): [number, number, number] {
  const v = l + s * Math.min(l, 1 - l);
  const sv = v === 0 ? 0 : 2 * (1 - l / v);
  return [h, sv, v];
}

// Example: Figma shows HSB(220, 85%, 90%)
// Convert to HSL for CSS:
const [h, sl, l] = hsvToHsl(220/360, 0.85, 0.90);
console.log(`hsl(${Math.round(h*360)}, ${Math.round(sl*100)}%, ${Math.round(l*100)}%)`);
// Output: hsl(220, 79%, 52%) — the actual CSS equivalent

// Verify round-trip:
const [h2, sv2, v2] = hslToHsv(h, sl, l);
console.log(`hsv(${Math.round(h2*360)}, ${Math.round(sv2*100)}%, ${Math.round(v2*100)}%)`);
// Output: hsv(220, 85%, 90%) — back to original

Copy and paste into your project — free to use.

Pro Tips

Developer Perspective

The Figma-to-CSS pipeline breaks here

A designer picks a vibrant brand blue in Figma's HSB picker: H=220, S=85%, B=90%. That looks punchy on screen. The developer reads the Figma export, sees HSL values, and writes `hsl(220, 85%, 90%)` — which renders as a pale sky blue, nothing like the mockup. The disconnect: 85% saturation means different things in each model. In HSV/HSB, S=85% means "mostly pure color with little white mixed in." In HSL, S=85% at L=90% means "very light tint with a hint of blue." The fix is converting properly, not eyeballing.

How the models actually differ

| Property | HSL | HSV (HSB) | |---|---|---| | Third axis | Lightness (0% = black, 100% = white, 50% = pure color) | Value/Brightness (0% = black, 100% = fully bright) | | Saturation at max | Full color at S=100%, L=50% | Full color at S=100%, V=100% | | White | S=0%, L=100% | S=0%, V=100% | | Black | Any S, L=0% | Any S, V=0% | | Pure red | hsl(0, 100%, 50%) | hsv(0, 100%, 100%) | | Shape metaphor | Double cone (bicone) | Single cone or cylinder |

Adobe tools default to HSB for a reason

Photoshop, Illustrator, and After Effects all use HSB in their native color pickers. The reasoning: artists think in terms of "how bright is this color" and "how much white have I mixed in." Value (brightness) maps more intuitively to paint mixing. You start with a vivid hue and either darken it (reduce V) or desaturate it (reduce S). HSL's lightness axis, where 50% is the "purest" point and both 0% and 100% kill the color, feels less natural for visual picking.

CSS chose HSL because it's symmetrical

The CSS working group adopted HSL because its lightness axis is symmetrical: 0% always equals black, 100% always equals white, regardless of hue. That makes programmatic manipulation predictable. `lighten(color, 10%)` always moves toward white. `darken(color, 10%)` always moves toward black. In HSV, achieving the same symmetry requires juggling both S and V simultaneously.

When Google's Material Design team converts

Material Design defines tonal palettes using lightness steps (0, 10, 20... 100). Internally they work in HCT (their custom model), but when exporting to web tokens they use HSL. When exporting to Android native, the picker shows HSV. The same palette, two different representations. Teams that don't understand the conversion get mismatched implementations between platforms.

When copying colors from Figma/Photoshop to CSS, never assume the saturation percentage transfers directly. Always convert HSB→HSL using the formula or a tool.

Use HSL for programmatic color manipulation (generating palettes, adjusting themes) because lightness behaves symmetrically — 0% is always black, 100% is always white.

Try It Yourself

Use these free tools to apply what you learned: