← 返回博客首页

颜色代码实战:HEX、RGB、HSL 的互转与 WCAG 对比度计算

颜色代码的转换公式网上一搜就有,但真正会卡住人的是两个更上层的问题:什么时候该用哪套坐标系,以及怎么判断颜色对是否真的达标。

本文补的是这两块。实时换算见站内 颜色转换器。

三套坐标系,各管什么

模型 通道 适合
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%,对比度的变化远非线性,直觉很难预判。调色板生成器 生成的色板则会自动包含满足正文对比度的深浅变体。


广告

常见问题

HEX、RGB、HSL 到底该用哪个?

**用途决定,不是优劣决定。** **HEX** 是最紧凑的表示(`#FF8800`,6 位),适合在代码里写字面量。**RGB** 是设备的原生模型(每个通道 0–255),适合编程时直接改数值。**HSL/HSB** 是**人眼模型**(色相 H 0–360°、饱和度 S、明度 L 0–100%),它的三个轴对应人脑感知的三件事,所以**调色时用 HSL 明显更可预测** —— 你把 L 从 50 调到 70,知道自己在「变亮」;而在 RGB 里把 R 从 200 调到 230,你不知道画面会变成什么样。经验规则:**代码里写 HEX,编程时用 RGB,调色与配色时用 HSL。** 本站的 [颜色转换器](/color-converter.html) 可随时互转。

WCAG 对比度怎么算,AA 和 AAA 差多少?

对比度不是简单地把 RGB 值相减,而要走**相对亮度**这条路:先把每个通道从 sRGB(0–1)做反伽马修正得到线性值,按 0.2126R + 0.7152G + 0.0722B 算出亮度 L1,背景同理得 L2,然后 ratio = (Lmax + 0.05) / (Lmin + 0.05)。阈值是 **AA:正文 4.5:1、大字号 3:1;AAA:正文 7:1、大字号 4.5:1**。大字号指 18pt(或 14pt 粗体)以上。用 HSL 的**明度 L 直接判断对比度是错的** —— 纯色的 L=100 和 L=80 亮度差远超直觉,黄色在 L=50 时对比度已经很高。

同一主色配深浅两种文字,怎么选?

**两种只能选一种,而且必须实测。** 同一个色相的深色文字与浅色文字,亮度差距不大时对比度可能一个达标一个不达标。最常见的失败模式是用 HSL 的 **L 值相同**来判断「深浅一致」——但 HSL 的 L 对不同色相的含义完全不同(黄色 L=50 已经很亮,蓝色 L=50 仍然很暗)。正确做法是**把前景与背景的对比度都算出来**,而不是靠色相或 L 值猜。本站的 [颜色转换器](/color-converter.html) 可以直接给出 WCAG 等级;[调色板生成器](/color-palette-generator.html) 生成的色板会内置必要的对比度约束。

← 返回博客首页