Treat HSL as an sRGB editing model
CSS Color 4 defines hsl() as a way to specify an sRGB color with hue, saturation, and lightness. It is not a wider gamut and it is not a perceptually uniform space. The browser converts the coordinates to red, green, and blue components. That makes HSL useful for manual edits while keeping its output compatible with ordinary HEX and rgb() values. It also means the resolved sRGB channels—not the three input labels—are the final evidence for comparison, storage, and contrast testing.
- Hue is an angle around the HSL cylinder: 0deg is red, 120deg is the sRGB green primary, and 240deg is the sRGB blue primary.
- Saturation 0% produces an achromatic result; 100% uses the maximum HSL saturation available at that hue and lightness.
- Lightness 0% resolves to black, 100% resolves to white, and 50% is the model midpoint—not a promise of medium perceived brightness.
- Alpha is a separate component. It changes how the color composites with its backdrop, not the stored hue, saturation, or lightness coordinates.
- HEX, rgb(), and hsl() can represent the same in-gamut sRGB result even though they expose different controls.
Write the syntax so every component is unambiguous
Modern CSS separates HSL components with spaces and puts alpha after a slash. The older comma-separated form remains for compatibility, but mixing the two grammars is invalid. Hue accepts an angle; a unitless zero is also valid, while explicit deg makes token documentation clearer. Angles wrap around the circle, so 0deg, 360deg, and 720deg resolve to the same hue. The examples below use opaque colors unless an alpha value is shown.
:root {
--accent: hsl(210deg 100% 40%);
--accent-translucent: hsl(210deg 100% 40% / 60%);
}
/* Valid legacy syntax, retained for compatibility. */
.legacy-example {
color: hsl(210, 100%, 40%);
}| HSL input | Resolved HEX | What it demonstrates |
|---|---|---|
| hsl(0 100% 50%) | #FF0000 | 0deg is red |
| hsl(360 100% 50%) | #FF0000 | Hue wraps |
| hsl(120 100% 50%) | #00FF00 | sRGB green primary |
| hsl(240 100% 50%) | #0000FF | sRGB blue primary |
| hsl(210 0% 50%) | #808080 | Hue cannot affect an achromatic result |
Saturation changes colorfulness, not opacity
Hold hue at 210deg and lightness at 50%, then reduce saturation in equal 25-point steps. The result moves toward the achromatic axis while remaining fully opaque. This is different from lowering alpha: a translucent blue reveals an unknown backdrop, while an HSL color at lower saturation already resolves to a new opaque sRGB triplet. The table uses the CSS Color 4 conversion algorithm and rounds final 8-bit channels to the nearest integer for HEX.
| Saturation | HSL | Resolved HEX | Resolved RGB |
|---|---|---|---|
| 100% | hsl(210 100% 50%) | #0080FF | 0 128 255 |
| 75% | hsl(210 75% 50%) | #2080DF | 32 128 223 |
| 50% | hsl(210 50% 50%) | #4080BF | 64 128 191 |
| 25% | hsl(210 25% 50%) | #60809F | 96 128 159 |
| 0% | hsl(210 0% 50%) | #808080 | 128 128 128 |
Lightness steps can cross the foreground threshold
Now keep hue 210deg and saturation 100% while changing HSL lightness. The sequence is easy to generate, but the appropriate foreground does not remain constant. Contrast below is calculated from each rounded HEX result using the WCAG sRGB relative-luminance formula. AA for normal text requires at least 4.5:1. The stronger of black and white is listed only as a tested choice for this pair, not as page-level accessibility certification.
| Lightness | Resolved HEX | Black text | White text | AA normal choice |
|---|---|---|---|---|
| 20% | #003366 | 1.67:1 | 12.61:1 | White |
| 30% | #004D99 | 2.52:1 | 8.33:1 | White |
| 40% | #0066CC | 3.77:1 | 5.57:1 | White |
| 50% | #0080FF | 5.53:1 | 3.80:1 | Black |
| 60% | #3399FF | 7.14:1 | 2.94:1 | Black |
| 70% | #66B3FF | 9.46:1 | 2.22:1 | Black |
| 80% | #99CCFF | 12.44:1 | 1.69:1 | Black |
- The foreground switches between the 40% and 50% steps in this particular scale.
- A hover or selected state generated from the same hue still needs its own text and non-text contrast checks.
- Do not round a ratio before comparing it with 4.5:1; round only the displayed result.
- If alpha, gradients, images, or overlays are present, calculate the final rendered colors before evaluating contrast.
HSL lightness is not perceived lightness
The word lightness sounds perceptual, but HSL lightness is derived from gamma-encoded sRGB channel extrema. CSS Color 4 explicitly warns that it does not track visual lightness across hues. Saturated yellow and saturated blue both have HSL lightness 50%, yet their WCAG relative luminance and useful foreground colors are dramatically different. Equal hue-angle changes are also not visually even: some regions of the HSL wheel appear compressed and others widely separated.
| Color | HSL | HEX | Relative luminance | Black text | White text |
|---|---|---|---|---|---|
| Yellow | hsl(60 100% 50%) | #FFFF00 | 92.78% | 19.56:1 | 1.07:1 |
| Blue | hsl(240 100% 50%) | #0000FF | 7.22% | 2.44:1 | 8.59:1 |
Avoid the six common HSL traps
- Do not treat HSL as a wide-gamut format. CSS hsl() resolves to sRGB and cannot preserve a Display P3 color that lies outside sRGB.
- Do not preserve a meaningful hue label on a zero-saturation gray; the hue component cannot affect the rendered result.
- Do not call an HSL lightness edit a tint or shade unless your project explicitly defines those words that way.
- Do not replace saturation with alpha. Saturation changes the opaque color; alpha changes compositing against a backdrop.
- Do not expect equal degree rotations or equal lightness steps to look equally spaced. HSL is not perceptually uniform.
- Do not repeatedly round HSL and overwrite a canonical RGB value. Fractional conversion results can accumulate channel drift.
One more subtlety is serialization. A browser may expose a resolved color in rgb() form even when the author wrote hsl(), because the used color is sRGB. For tests, compare resolved channels or a deliberately normalized representation rather than requiring the original source string. For design tokens, record whether HSL coordinates are the canonical editable source or merely a derived presentation, then keep that decision consistent.
Ship HSL tokens with resolved evidence
A practical token set can use HSL without pretending every numeric step is safe. Keep semantic names, record the coordinates, resolve the final sRGB values in your build or documentation, and test every actual foreground/background pair. The example uses white on the 40%-lightness action color because that pair calculates to 5.57:1. The translucent disabled token still requires a known backdrop before any contrast conclusion.
:root {
--action: hsl(210 100% 40%); /* #0066CC */
--action-hover: hsl(210 100% 32%);
--action-disabled: hsl(210 20% 55% / 60%);
--on-action: #FFFFFF; /* 5.57:1 on --action */
}
.action {
color: var(--on-action);
background: var(--action);
}
.action:hover {
background: var(--action-hover);
}- Keep one canonical value and enough precision to reproduce the resolved channels.
- Document which coordinate may change and which semantic role the resulting token serves.
- Export copy-ready HSL and a resolved HEX or RGB value for review across tools.
- Test default, hover, active, focus, selected, disabled, light-theme, and dark-theme pairs separately.
- Measure contrast from the final composited colors and retain the unrounded ratio in audit data.
- Use OKLCH or another suitable space when perceptually even palette steps matter more than familiar HSL controls.
- Link the conversion method and source specification so future generator changes remain explainable.
Primary sources
The factual claims in this article are checked against these published specifications.