Color Picker & Converter

Why HSL Lightness Lies to Your Eyes — and What OKLCH Fixes

HSL feels perceptual because it names its third axis "lightness", but under the hood it is nothing more than a cylindrical remap of gamma-encoded sRGB coordinates. The L value is literally (max + min) / 2 of the three RGB channels — it knows nothing about how human vision weights different wavelengths. The classic demonstration: hsl(60, 100%, 50%) is pure yellow and hsl(240, 100%, 50%) is pure blue. Both report L = 50%, yet their relative luminance is 0.928 versus 0.072 — the yellow is more than twelve times brighter to your eye. The consequence hits anyone who builds a palette by rotating hue at a fixed HSL lightness: the yellow and cyan stops come out glowing while the blue and purple stops look nearly black, and white text that passes contrast checks on one swatch fails on its neighbor. Teams then hand-tune every stop, which defeats the point of a systematic palette in the first place. The modern fix is OKLCH, standardized in CSS Color Module Level 4 and available in every major browser since 2023 (Chrome 111, Safari 15.4, Firefox 113). Its L axis is perceptually uniform: oklch(70% 0.1 60) and oklch(70% 0.1 240) genuinely look equally light, so rotating hue at fixed L and chroma produces ramps whose contrast against white or black stays consistent. Use HSL for quick one-off tweaks; reach for OKLCH the moment a palette has to be systematic.
/* Same HSL "lightness", wildly different brightness */
.hsl-yellow { background: hsl(60 100% 50%); }  /* luminance 0.928 */
.hsl-blue   { background: hsl(240 100% 50%); } /* luminance 0.072 */

/* OKLCH: the L axis is perceptual, so these really match */
.ok-yellow { background: oklch(70% 0.15 100); }
.ok-blue   { background: oklch(70% 0.15 260); }

/* Hue-rotated ramp with constant perceived lightness */
:root {
  --cat-1: oklch(70% 0.12 30);
  --cat-2: oklch(70% 0.12 150);
  --cat-3: oklch(70% 0.12 270);
}

WCAG Contrast Math You Can Compute Yourself

A WCAG contrast ratio is not a black box — it is ten lines of code. First linearize each sRGB channel: divide by 255, then if the result is at or below 0.04045 divide it by 12.92, otherwise compute ((c + 0.055) / 1.055) raised to the power 2.4. Relative luminance is the weighted sum 0.2126 R + 0.7152 G + 0.0722 B. The weights encode how strongly each primary drives perceived brightness, which is why green dominates and blue barely registers. The contrast ratio between two colors is (L1 + 0.05) / (L2 + 0.05) with the lighter luminance as L1, yielding values from 1:1 up to 21:1 (pure black on pure white). The thresholds worth memorizing: 4.5:1 for normal body text at level AA, 3:1 for large text (roughly 24px, or 18.66px bold) and — since WCAG 2.1 SC 1.4.11 — for non-text UI such as input borders, focus rings and icons, and 7:1 for AAA. A useful calibration point: #767676 on white measures 4.54:1, making it the lightest gray that still passes AA for body text. The ubiquitous #999999 measures only 2.85:1 and fails; placeholder and disabled-label grays are the most common findings in real accessibility audits. One caveat: the WCAG 2.x formula is known to over-reward light-on-dark pairs, so dark-mode text that passes 4.5:1 numerically can still read worse than its light-mode mirror. Treat 4.5:1 as a floor in dark themes, not a target.
function luminance(r, g, b) {
  const lin = (c) => {
    c /= 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  };
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}

function contrast(rgb1, rgb2) {
  const L1 = luminance(...rgb1);
  const L2 = luminance(...rgb2);
  const [hi, lo] = L1 > L2 ? [L1, L2] : [L2, L1];
  return (hi + 0.05) / (lo + 0.05);
}

contrast([118, 118, 118], [255, 255, 255]); // 4.54 — #767676 barely passes AA
contrast([153, 153, 153], [255, 255, 255]); // 2.85 — #999999 fails

Hex Notation Corner Cases: 3, 4, 6 and 8 Digits

CSS accepts four hex lengths: #RGB, #RGBA, #RRGGBB and #RRGGBBAA. Short forms expand by doubling each digit, not by zero-padding: #f0c becomes #ff00cc and #f0c8 becomes #ff00cc88. That distinction matters when you shorten values by hand — #abc means #aabbcc, never #0a0b0c. The 8-digit form puts alpha at the end. Android (and older .NET/WPF) uses ARGB with alpha first: #AARRGGBB. So the same string #80FF0000 means 50%-opacity pure red on Android, but in CSS it parses as red 0x80, green 0xFF, blue 0x00, alpha 0x00 — a fully transparent chartreuse. Nothing renders, no error is thrown, and the bug typically survives until someone diffs the two codebases character by character. When porting colors between an Android app and web CSS, always re-order the alpha byte. Alpha percentages do not map cleanly onto hex either. 50% opacity is 0.5 × 255 = 127.5, which rounds to 128 = 0x80 — so half-transparent is #...80, not the #...7F many people write from intuition. Hex offers only 255 discrete alpha steps, while rgb(r g b / 50%) keeps the exact fraction, which is why design tokens increasingly prefer the slash syntax. Finally, hex is an sRGB-only notation. There is no wide-gamut hex: a Display-P3 color simply has no hex spelling, so any tool that exports P3 values to hex is silently gamut-mapping them into sRGB first.
#f0c   -> #ff00cc     (digit doubling, not zero padding)
#f0c8  -> #ff00cc88   (4-digit form is RGBA)

50% alpha: 0.5 x 255 = 127.5 -> rounds to 0x80
  CSS 50% red:     #FF000080   (RRGGBBAA, alpha last)
  Android 50% red: #80FF0000   (AARRGGBB, alpha first)

/* Same string, different platform, silent bug: */
CSS #80FF0000 = rgba(128, 255, 0, 0)  ->  invisible

sRGB vs Display-P3: When rgb(255 0 0) Is Not the Reddest Red

Every hex and rgb() value on this page lives in sRGB, a gamut standardized in 1996 around CRT phosphors. Displays moved on: every iPhone since the 7, every MacBook since 2016, and most flagship Android and premium Windows panels cover Display-P3, which holds roughly 25% more colors than sRGB, with the biggest gains in saturated reds, oranges and greens. CSS Color 4 makes them addressable: color(display-p3 1 0 0) renders a visibly deeper red than rgb(255 0 0) on a P3 screen, while on an sRGB screen both collapse to the same clamped value. This spills into daily workflows in sneaky ways. macOS screenshots are tagged Display-P3, so a color picked from a screenshot will not numerically match the CSS that produced it. Figma documents set to P3 shift when exported or viewed on sRGB hardware — the classic "it looks oversaturated on my Mac but dull on the office monitor" bug report traces to gamut conversion, not to your CSS. And some brand colors, especially saturated oranges, sit outside sRGB entirely, meaning no hex code can represent them faithfully. Practical guidance: keep sRGB values as the baseline, layer wide-gamut colors behind an @supports or @media (color-gamut: p3) check, and remember that older browsers skip declarations they cannot parse — so writing the sRGB fallback first and the color(display-p3 ...) override on the next line degrades gracefully for free.

Deriving Hover, Active, Disabled and Dark-Mode Colors

The everyday reason to convert into HSL is deriving interaction states. Shifting lightness by 8–10 points — darker for hover, darker still for active — works well for mid-range colors around L 40–60%. It breaks near the extremes: at L 92% a +8 shift clips into pure white, and near L 10% the same shift changes the color's character entirely. Clamp derived lightness to roughly the 5–95% band and shift saturation instead when you hit the rails. Dark mode is not inverting lightness. Flipping L preserves hue but yields pure white text on pure black surfaces, which produces halation (glow) on OLED panels and is measurably harder to read for astigmatic users. Material Design's guidance is a near-black surface around #121212 rather than #000000, with desaturated accent tones — saturated brand colors that look right on white vibrate against dark surfaces. Reduce chroma and raise lightness a little instead of reusing light-mode tokens verbatim. For disabled states, prefer alpha over a lighter gray: an element at 38% opacity reads as disabled on any background, while a hardcoded #C0C0C0 breaks the moment it sits on a tinted card. Last, mind rounding. This converter, like CSS itself, rounds H to whole degrees and S/L to whole percents — a grid of about 3.6 million HSL points against 16.7 million RGB values. Round-tripping can therefore drift a channel by ±1. Store one canonical format (usually hex) as the source of truth and derive the rest from it, rather than converting back and forth.
Last updated:

About this tool

A color converter translates between the color models you actually use in code: HEX (#FACC15) for CSS, RGB for canvas and image processing, and HSL for design systems and theming. HSL exposes hue, saturation, and lightness as separate axes, which makes it dramatically easier to derive consistent palettes, hover states, and dark-mode variants than tweaking RGB by hand.

How to use

  1. Pick a color in the visual color picker, or type a HEX value directly.
  2. The RGB and HSL fields update live so you can read off any representation.
  3. Edit any individual channel (R, G, B, H, S, or L) to nudge the color along that axis.
  4. Copy the format your code needs — for example, hsl(48, 96%, 53%) for a Tailwind config or #FACC15 for CSS.
  5. Iterate by adjusting L for a hover state or S for a muted variant of the same hue.

Common use cases

  • Converting a designer-supplied HEX from Figma to the rgba() format for a CSS shadow.
  • Generating hover and active states by adjusting only the lightness channel.
  • Building a Tailwind config palette in HSL so opacity utilities work cleanly.
  • Sampling a brand color and reproducing it in canvas/SVG code that needs RGB.
  • Producing accessibility-friendly variants by tracking lightness contrast.
  • Translating colors between dark mode and light mode by inverting lightness while keeping hue constant.

Frequently asked questions

Q. Why does the same HEX look different in two browsers?

A. Color profile handling. sRGB is assumed unless a profile is specified, but some browsers and OS-level color management apply additional gamma. The HEX value itself does not change.

Q. When should I prefer HSL over RGB?

A. When you need to derive related colors. HSL lets you change brightness or saturation independently of hue, which is impossible with RGB without color theory math.

Q. What about HSV / HSB — are those the same as HSL?

A. No. HSV and HSL share hue and saturation but differ on the third axis: HSV uses Value (peak brightness), HSL uses Lightness (midpoint between black and white). Most CSS workflows prefer HSL.

Q. How do I add transparency?

A. Use rgba() or hsla() with an alpha between 0 and 1, or modern CSS color-mix / 8-digit hex (#FACC15CC) where supported.