前端颜色体系:HEX/RGB/HSL 互转与设计系统的颜色令牌实践Frontend Colour Systems: Converting HEX/RGB/HSL and Colour Tokens in Design Systems
HEX、RGB、HSL 不是三种"不同的颜色",而是同一种颜色的三种描述方式。本文讲清三者的本质与互转公式,再落到设计系统里颜色令牌(Design Token)的组织实践HEX, RGB and HSL are not three "different colours" — they are three ways of describing the same colour. This article explains their essence and conversion formulas, then moves to colour-token organisation in real design systems.
三种颜色模型的本质The essence of three colour models
刚做前端时,我以为 HEX 是"设计师用的"、RGB 是"CSS 用的"、HSL 是"高级玩法",三者之间有某种不可逾越的鸿沟。后来才搞清楚:它们描述的都是 sRGB 色彩空间里的同一个点,只是坐标系不同。When I started in frontend, I thought HEX was "for designers," RGB was "for CSS," and HSL was "advanced stuff" — three separate worlds. It turned out they all describe the same point in sRGB space, just with different coordinate systems.
HEX 本质上就是 RGB 的十六进制写法。`#FF8800` 里,FF 是红色通道(255)、88 是绿色通道(136)、00 是蓝色通道(0)。带 Alpha 的 `#FF8800CC` 末尾两位就是不透明度(204/255 约等于 80%)。所以 HEX 和 RGB 之间是纯进制转换,没有任何色彩学上的差异。HEX is essentially RGB written in hexadecimal. In `#FF8800`, FF is the red channel (255), 88 is green (136), and 00 is blue (0). With alpha, `#FF8800CC` appends two digits for opacity (204/255, roughly 80%). So HEX and RGB are pure base conversion — no colour-science difference at all.
RGB 的问题是"不直觉"。想把一个颜色调亮 20%,在 RGB 里你得同时动三个通道,而且动多少没有直观规律。HSL 就是为解决这个问题设计的:H(Hue,色相)是 0 到 360 度的色轮位置,S(Saturation,饱和度)是颜色的鲜艳程度,L(Lightness,亮度)是明暗。调亮只需要把 L 从 50% 改成 70%,这就是为什么 HSL 在需要程序化生成颜色变体的场景里特别好用。The problem with RGB is that it's unintuitive. To lighten a colour by 20%, you have to adjust all three channels, and by how much is not obvious. HSL was designed to fix this: H (Hue) is a 0 to 360 position on the colour wheel, S (Saturation) is vividness, L (Lightness) is brightness. To lighten, just change L from 50% to 70% — which is why HSL shines when you need to programmatically generate colour variants.
互转公式与手动验算Conversion formulas and manual verification
HEX 转 RGB 最简单:每两位一组按十六进制解析。`#3498DB` 对应 R=0x34=52,G=0x98=152,B=0xDB=219。我在调试颜色时经常用这个心算,不需要开工具。HEX to RGB is the simplest: parse each pair as hexadecimal. `#3498DB` gives R=0x34=52, G=0x98=152, B=0xDB=219. I do this mental arithmetic all the time when debugging colours — no tool needed.
RGB 转 HSL 稍微复杂,但值得理解。第一步把 R、G、B 都除以 255 归一化到 0 到 1。第二步求最大值 max、最小值 min,以及差值 delta = max 减 min。亮度 L = (max + min) / 2。饱和度 S 在 delta 为 0 时是 0(灰色),否则 S = delta / (1 减 |2L 减 1|)。色相 H 的计算分三种情况:如果 max 是 R,H = 60 乘 (((G 减 B)/delta) mod 6);如果 max 是 G,H = 60 乘 ((B 减 R)/delta + 2);如果 max 是 B,H = 60 乘 ((R 减 G)/delta + 4)。结果为负就加 360。RGB to HSL is a bit more involved but worth understanding. First, normalise R, G, B to 0 to 1 by dividing by 255. Then find max, min, and delta = max minus min. Lightness L = (max + min) / 2. Saturation S is 0 when delta is 0 (grey), otherwise S = delta / (1 minus |2L minus 1|). Hue H has three cases: if max is R, H = 60 times (((G minus B)/delta) mod 6); if max is G, H = 60 times ((B minus R)/delta + 2); if max is B, H = 60 times ((R minus G)/delta + 4). Add 360 if the result is negative.
我用 `#3498DB`(R=52, G=152, B=219)验算过:归一化后 max=0.859(B),min=0.204(R),delta=0.655,L=0.531,S=0.655/(1 减 |2乘0.531 减 1|)=0.655/0.938 约等于 0.698,H=60 乘 ((0.204 减 0.596)/0.655 + 4)=60 乘 3.4=204 度。所以 `#3498DB` 对应的 HSL 是 hsl(204, 70%, 53%),和设计工具里显示的一致。I verified this with `#3498DB` (R=52, G=152, B=219): normalised, max=0.859 (B), min=0.204 (R), delta=0.655, L=0.531, S=0.655/(1 minus |2 times 0.531 minus 1|)=0.655/0.938, roughly 0.698, H=60 times ((0.204 minus 0.596)/0.655 + 4)=60 times 3.4=204 degrees. So `#3498DB` maps to hsl(204, 70%, 53%), matching what design tools show.
设计系统里的颜色令牌Colour tokens in design systems
理解了三种模型之后,真正的工程问题是:怎么在一个中大型前端项目里组织几十上百个颜色,让设计师改一个主色,全站自动同步?答案是颜色令牌(Colour Token / Design Token)。Once you understand the three models, the real engineering question is: how do you organise dozens or hundreds of colours in a mid-to-large frontend project, so that when a designer changes the brand colour, the whole site updates automatically? The answer is colour tokens (design tokens).
我参与过的一个后台系统,早期颜色是直接写在组件里的:`color: #1890ff`、`border-color: #d9d9d9`。后来品牌升级要把主色从蓝改成青,全局搜替换了三百多处,还漏了十几个 hover 态。之后我们重构为三层令牌结构:最底层是"原始色板"(primitive),如 `blue-500: #3b82f6`;中间层是"语义令牌"(semantic),如 `color-primary` 引用 `blue-500`、`color-danger` 引用 `red-500`;最上层是"组件令牌"(component),如 `button-primary-bg` 引用 `color-primary`。On an admin system I worked on, colours were originally hard-coded in components: `color: #1890ff`, `border-color: #d9d9d9`. When a brand refresh moved the primary from blue to teal, we did a global find-and-replace of 300-plus occurrences and still missed a dozen hover states. Afterwards we refactored into a three-layer token structure: the bottom layer is "primitive palette," e.g. `blue-500: #3b82f6`; the middle is "semantic tokens," e.g. `color-primary` references `blue-500`, `color-danger` references `red-500`; the top is "component tokens," e.g. `button-primary-bg` references `color-primary`.
这样品牌升级只需要改语义令牌的映射,组件代码完全不动。而且配合 CSS 变量,可以在运行时切换主题:暗色模式下把 `color-primary` 指向 `blue-400`(更亮以保证对比度),一行代码搞定。With this structure, a brand refresh only changes the semantic-token mapping — component code stays untouched. Combined with CSS variables, you can switch themes at runtime: in dark mode, point `color-primary` to `blue-400` (brighter for contrast), one line of code.
关键经验是:原始色板用 HEX 存储(精确、无歧义),语义令牌用引用而非复制值,组件令牌尽量少——能用语义令牌就不要新建组件级令牌,否则令牌数量会爆炸。Key lessons: store the primitive palette in HEX (precise, unambiguous), make semantic tokens references rather than copied values, and keep component tokens minimal — use semantic tokens wherever possible, or token count explodes.
踩坑:透明度、sRGB 与暗色模式Pitfalls: alpha, sRGB and dark mode
第一个坑是透明度叠加。`rgba(52, 152, 219, 0.5)` 叠在白色背景上看起来是浅蓝色,但叠在深色背景上完全是另一个颜色。我曾经把一个半透明遮罩从白背景页移到黑背景页,结果遮罩"不见了"——因为它和背景混色后对比度不够。正确做法是:需要精确颜色时用不透明色,半透明只用于"和背景融合"的视觉效果。Pitfall one is alpha compositing. `rgba(52, 152, 219, 0.5)` over white looks light blue, but over a dark background it's a completely different colour. I once moved a semi-transparent overlay from a white page to a black one and the overlay "disappeared" — because after blending with the background, contrast was insufficient. Rule of thumb: use opaque colours when you need precision; reserve semi-transparency for visual effects that intentionally blend with the background.
第二个坑是 sRGB 与色域。绝大多数屏幕和 CSS 颜色都在 sRGB 空间,但现在 Display P3 广色域屏越来越多。如果你在 P3 屏上取色得到一个 sRGB 里不存在的高饱和绿,直接写成 `#00ff00` 会被钳位(clamp)到 sRGB 的最绿,颜色明显发灰。需要广色域时用 `color(display-p3 0 1 0)` 语法,并做好 sRGB 降级。Pitfall two is sRGB versus colour gamut. Most screens and CSS colours live in sRGB, but Display P3 wide-gamut screens are increasingly common. If you pick a highly saturated green on a P3 screen that doesn't exist in sRGB, writing it as `#00ff00` clamps to sRGB's greenest green, which looks noticeably duller. For wide gamut, use `color(display-p3 0 1 0)` with an sRGB fallback.
第三个坑是暗色模式下的对比度。很多团队直接把亮色模式的颜色"反相"做暗色模式,结果文字对比度不达标(WCAG AA 要求正文 4.5:1)。正确做法是暗色模式重新选色:背景用深灰(`#1a1a1a`)而非纯黑,文字用浅灰(`#e0e0e0`)而非纯白,这样长时间阅读更舒服,对比度也更容易达标。Pitfall three is contrast in dark mode. Many teams simply "invert" light-mode colours for dark mode, and text contrast ends up failing WCAG AA (4.5:1 for body text). The right approach is to re-pick colours for dark mode: backgrounds in dark grey (`#1a1a1a`) rather than pure black, text in light grey (`#e0e0e0`) rather than pure white — more comfortable for long reading and easier to hit contrast targets.