颜色代码的转换公式网上一搜就有,但真正会卡住人的是两个更上层的问题:什么时候该用哪套坐标系,以及怎么判断颜色对是否真的达标。
本文补的是这两块。实时换算见站内 颜色转换器。
三套坐标系,各管什么
| 模型 | 通道 | 适合 |
|---|---|---|
| HEX | #RRGGBB |
代码里的字面量 |
| RGB | R/G/B 各 0–255 | 编程时直接改数值 |
| HSL | H 0–360°、S/L 0–100% | 调色、配色、生成色板 |
HEX 本质是 RGB 的十六进制简写,没有任何额外信息 —— #FF8800 就是 R=255、G=136、B=0。所以这两者的转换是纯进制换算,不涉及任何计算。
HSL 才是真正的另一个视角。RGB 描述「每个通道发多少光」,HSL 描述「这个颜色是什么色相、有多纯、有多亮」。
这个区别在调色时体现得非常明显:
在 HSL 里:L 从 50 调到 70 → 你知道画面变亮了
在 RGB 里:R 从 200 调到 230 → 你不知道会变成什么(可能更亮、可能更暗、可能偏色)
RGB 通道之间存在耦合(增加 R 会同时改变明度与色相),所以RGB 不适合做「只调亮度」的操作。
实现换算:三个公式
HEX ↔ RGB —— 纯进制:
// HEX → RGB
const n = parseInt(hex.slice(1), 16)
const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255]
// RGB → HEX
const hex = '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join('')
RGB → HSL —— 归一化后算 max/min:
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
const l = (max + min) / 2 // 明度
if (max === min) return { h: 0, s: 0, l } // 灰色
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
let h
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0))
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
return { h: h * 60, s, l } // 色相 0–360°
}
HSL → RGB —— 反向,注意分支:
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360 / 360
if (s === 0) { const v = Math.round(l * 255); return [v, v, v] }
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
const f = t => {
t = (t + 1) % 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
return [f(h + 1 / 3), f(h), f(h - 1 / 3)].map(v => Math.round(v * 255))
}
对比度:不能靠 HSL 的 L 判断
这是最常见的错误。
WCAG 的对比度走的是相对亮度,不是 HSL 的明度:
1. 每个 sRGB 通道 c(0–1)做反伽马修正:c ≤ 0.03928 ? c/12.92 : ((c+0.055)/1.055)^2.4
2. 亮度 L = 0.2126R + 0.7152G + 0.0722B
3. 对比度 = (Lmax + 0.05) / (Lmin + 0.05)
阈值:
| 等级 | 正文 | 大字号 |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.5:1 |
大字号指 18pt(或 14pt 粗体)以上。
为什么不能用 HSL 的 L 判断? 因为 HSL 的 L 是各色相共用的归一化值,而亮度是物理量。举几个反直觉的例子:
- 纯黄
#FFFF00的 HSL L=50%,但相对亮度 0.928 —— 白底上对比度仅 1.07:1,完全不可读 - 纯蓝
#0000FF的 HSL L=50%,但相对亮度 0.072 —— 白底上对比度 8.59:1,轻松过 AA - 同一个 L=50 的灰色,亮度 0.184,白底对比度 4.73:1
所以同一主色配深浅文字时,必须实测两组的对比度,不能靠 L 值相同就认为「深浅一致」。
一个真实的临界案例
白底上:
| 前景色 | 对比度 | AA 正文 | AAA 正文 |
|---|---|---|---|
#767676 |
4.54:1 | ✅ | ❌ |
#777777 |
4.48:1 | ❌ | ❌ |
差一个色阶(1 个十六进制值),就从达标变成不达标。 这也是为什么按钮文字色要做成设计令牌而不是随手写 —— 手动调色时很容易差这一步。
自查清单
- [ ] 代码字面量用 HEX,编程调整用 RGB,调色配色用 HSL
- [ ] 用 HSL 调亮度而不是 RGB(RGB 通道有耦合)
- [ ] 对比度走相对亮度公式,不用 HSL 的 L
- [ ] 正文 4.5:1(AA)/ 7:1(AAA),大字号 3:1 / 4.5:1
- [ ] 同一主色的深浅两种文字都算一遍,不靠 L 值猜
- [ ] 颜色做成设计令牌,便于统一调整与明暗切换
可复现的实测结果
用本站的 颜色转换器 输入纯黄 #FFFF00,切换到 HSL 会看到 L=50%,但对比度栏显示 1.07:1 —— 这正是「HSL 的 L 不是亮度」的最好演示。再把同一色相的 L 调到 30% 与 70%,对比度的变化远非线性,直觉很难预判。调色板生成器 生成的色板则会自动包含满足正文对比度的深浅变体。