
上周帮人看一个后台页面设计师稿子里按钮的 hover 态是浅蓝落到代码里渲染成一块发灰的紫。翻了半天问题就出在一行background: #6ea8fe33—— 作者想表达亮一点的蓝把最后两位当成了亮度实际上那是 alpha 通道。这类事故在HTML 颜色值这块特别高频因为它的门槛看起来太低无非红绿蓝三个数能写错到哪去结果就是名色拼错、百分比和整数混写、透明度用 opacity 顶替、渐变到 transparent 出现灰边一个接一个。我写这一篇是想把颜色值从会写推到写对、写稳、写得好维护。内容覆盖 hex 与 rgb 的字节逻辑、HSL 的调色直觉、oklch 这类新色彩空间凭什么值得关注、CSS 变量和暗色模式下的颜色组织方式、对比度的实际算法以及我自己踩过的那些坑。前端新手能拿去当手册查写过几年的人也能在色彩空间和设计令牌这两块拿到点新东西。所有示例都是纯静态 HTML/CSS复制到一个空白页里就能跑起来验证。1. 先把地图画清楚HTML 里到底有几种颜色写法1.1 五种主流语法和一键对照表刚接触这块的人容易有个误解以为颜色值是 HTML 的语法。严格讲HTML 层只负责把颜色挂到属性上真正解析颜色值的是 CSS 的颜色模块。你在style属性里写color: red或者在font color#ff0000这种上古写法里塞值最终都交给同一套解析器。所以讨论颜色值本质是讨论 CSS 的颜色语法只是它经常出现在 HTML 文档的 style 里叫顺口了就成了HTML 颜色值。主流写法有五类我把它们放在一张表里对比方便你按场景挑语法示例可读性alpha 支持适用场景十六进制#2b6cf6/#2b6cf680中需要脑内换算支持后两位设计稿直接导出、老项目、写死的主色rgb()rgb(43 108 246)低三个数看不出色相支持斜杠后程序生成、需要动态拼字符串hsl()hsl(219 90% 57%)高能看出色相和明暗支持斜杠后手工调色、批量生成色阶命名色rebeccapurple最高但不可控不支持原型、Demo、语义色currentColor现代色彩空间oklch(0.58 0.19 258)中需要一点训练支持新项目、需要广色域或感知均匀选型的判断标准其实很简单这个颜色要不要被人反复改。设计稿导出的固定色十六进制最省事工具链原生支持主题色、状态色这种会随需求调的东西用 HSL 或者带变量的形式存起来改一个数字全站跟着走只有一处用一次的装饰色直接写死没人管你。我在项目里踩过的最大教训是一个品牌色在十几个文件里以十六进制形式硬编码后来甲方要换色我 grep 替换了半小时还漏掉两处写在linear-gradient里的。从那以后凡是会变的颜色一律进变量。1.2 命名颜色与两个特殊关键字CSS 一共提供了一百四十多个命名颜色从基础的red、blue到tomato、goldenrod、rebeccapurple这些。它们其实就是十六进制的别名解析时一模一样red和#ff0000完全等价。命名色的价值在于可读性和可搜索性写完border: 1px solid tomato一眼就知道是番茄红而#ff6347得查表。但它的问题也明显色值不可调你没法把tomato调暗 10%也没法加透明度。所以我的一般原则是原型阶段随便用进生产前统一换成具体色值或变量。有两个特殊关键字值得单独说因为它们不是普通颜色transparent它的实际值是rgba(0, 0, 0, 0)也就是透明的黑不是透明的白也不是没有颜色。这个细节在渐变里会咬人。currentColor取当前元素的color计算值。它让边框、背景、图标自动跟随文字颜色写主题切换时极其省事。svg里fill: currentColor是标配。注意transparent是透明黑做linear-gradient(to bottom, #f60, transparent)时中间会经历橙 → 灰 → 透明的过程肉眼看到的是发灰发脏的过渡。正确做法是渐变到同色的零 alpha 版本linear-gradient(to bottom, #f60, #f600)或者rgb(255 102 0 / 0)。currentColor还有个常见误解它只在color属性上有意义不是继承任意颜色。如果一个元素的祖先设了color: red那这个元素的currentColor就是红的如果没人设就是浏览器默认的黑色。1.3 新色彩空间的现状哪些能上生产如果你最近翻过 MDN 的颜色文档会被lab、lch、oklab、oklch、hwb、color()、color-mix()、相对颜色语法这一堆名词砸晕。我的判断是hwb可以忽略lab/lch了解概念即可oklch和color-mix()值得认真学因为它们解决的是真实问题。hwb是色相 白度 黑度思路直观但工具链支持一般没多少设计师会用它出稿。lab和lch基于 CIELAB 色彩模型理论严谨但数值范围反直觉L 是 0 到 100a、b 通道有正有负写起来不顺手。oklab/oklch是它的改良版数值范围更规整L 是 0 到 1或 0% 到 100%C 是彩度H 是色相角度。真正的分水岭是感知均匀性。HSL 里的 50% 亮度落到黄色和蓝色上肉眼感受到的明暗差别巨大而 oklch 里相同的 L 值不同色相看起来亮度基本一致。这个特性对生成色阶、做暗色模式至关重要。我在一个多主题项目里用 HSL 生成过十级色阶结果黄色系那一列明显比蓝色系亮得早最后不得不手动给每个色相配不同的亮度表。换成 oklch 之后一张表通用代码少了一半。兼容性上主流浏览器对 oklch 的支持已经很完整但如果你还要照顾旧环境就得准备回退写法这个第 4 章会细讲。结论先给新项目可以放心用老项目先别动。2. 十六进制与 RGB 的底层字节、通道和 alpha 合成2.1 为什么是 0-255一个通道一个字节很多人第一次写rgb(255, 0, 0)时会问为什么上限是 255 不是 100答案在存储。早期图形系统里每个颜色通道用一个字节存一个字节 8 位能表示 0 到 255 共 256 个值。三个通道 24 位加上 alpha 通道 32 位这就是常说的24 位真彩色和32 位色。这个约定从硬件层一路传到 CSS 规范里成了今天的默认写法。理解这一点很多东西就自然通了。为什么十六进制是#RRGGBB六位因为每个通道两位十六进制刚好覆盖 0 到 255ff等于 255。为什么八位写法#RRGGBBAA的最后两位是 alpha因为多出来的那个字节就存在这里。为什么不建议在 HTML 里写rgb(100%, 0%, 0%)因为百分比和你脑子里的 0-255 刻度是两套体系混着写极易出错。顺带说一个实际影响8 位色深意味着相邻两个色值之间的差异是 1/255肉眼在大多数屏幕上分辨不出来。所以做渐变色阶时没必要生成 100 级8 到 12 级足够再多是浪费。2.2 十六进制换算与缩写规则附转换脚本十六进制的转换是硬功夫得会。规则不复杂0-9表示 0 到 9a-f表示 10 到 15两位组合的值是高位乘 16 加低位。#ff0000拆成ff、00、00ff是 15×1615255所以是纯红。#2b6cf6拆成2b、6c、f6分别是 43、108、246。缩写规则只有一条每个通道的两位数字相同才能缩成一位。#aabbcc能缩成#abc#ff8800能缩成#f80但#123456不能缩因为1和2不一样。这个规则很多人记反了以为只要够短就能缩。实测下来缩写带来的收益很小省三个字符风险却是实打实的看错我的建议是除非手写调试否则一律写六位。写脚本时经常需要在 hex 和 rgb 之间来回转我常备这么一段// hex - [r, g, b] function hexToRgb(hex) { let h hex.replace(#, ).trim(); if (h.length 3) h h.split().map(c c c).join(); if (h.length ! 6 h.length ! 8) throw new Error(非法颜色值: hex); const num parseInt(h.slice(0, 6), 16); return [(num 16) 255, (num 8) 255, num 255]; } // [r, g, b] - hex function rgbToHex(r, g, b) { const clamp v Math.max(0, Math.min(255, Math.round(v))); return # [r, g, b].map(v clamp(v).toString(16).padStart(2, 0)).join(); } console.log(hexToRgb(#2b6cf6)); // [43, 108, 246] console.log(rgbToHex(43, 108, 246)); // #2b6cf6这段代码里有个细节值得说parseInt解析八位值时要先slice(0, 6)否则 alpha 会被算进数值里得到一个超出范围的结果。另外clamp那一步别省动画插值时很容易算出 256 或 -1不截断就会输出#100这种畸形值。2.3 alpha 不是亮度透明度合成的真实效果回到开头那个事故。八位十六进制里最后两位是 alpha00全透明ff全不透明中间是线性刻度。#6ea8fe33里的33是 51/255 约等于 20% 不透明度所以渲染出来是一层很淡的蓝压在页面背景上。如果背景是灰的你看到的就是灰蓝如果背景是白的看到的就是浅蓝。作者想要亮一点的蓝得到的是淡一点的蓝两者在视觉上完全是两回事。这里有个必须搞清的认知alpha 值描述的是遮挡程度不是颜色本身的亮度。降低 alpha 意味着让底色透出来最终呈现的颜色取决于背景。同样的半透明红压在白底上是粉红压在黑底上是暗红。所以任何依赖透明度的设计都必须考虑底层背景。合成公式是结果 前景色 × α 背景色 × (1 - α)逐通道计算。举个例子rgba(255, 0, 0, 0.5)压在白底#ffffff上红通道是 255×0.5 255×0.5 255绿通道是 0×0.5 255×0.5 127.5取整 128结果是rgb(255, 128, 128)的粉色。这个算法很实用做像素级复现时按它推一遍就能对色。还有个连锁问题多层半透明叠加时不能把 alpha 直接相加。两层 50% 叠加的最终不透明度是1 - (1-0.5)² 0.75不是 1。我见过有人为了让蒙层更浓把两层 50% 叠在一起以为等于不透明结果还是能透出内容白调了半天。2.4 逗号语法与空格语法的分水岭rgb(255, 0, 0)和rgb(255 0 0)都能用但两者背后是两代规范。逗号语法是 CSS Color 3 的产物rgba()是一个独立函数空格加斜杠是 CSS Color 4 的写法rgb()本身就能带 alpha不再需要rgba这个别名。/* 老写法 */ color: rgb(43, 108, 246); color: rgba(43, 108, 246, 0.5); /* 新写法 */ color: rgb(43 108 246); color: rgb(43 108 246 / 0.5); color: rgb(43 108 246 / 50%);新写法在可读性上更好尤其是加了 alpha 之后斜杠清晰地把颜色和透明度分开。而且新语法里的rgb()和rgba()完全等价你可以统一只用rgb()。注意新旧语法不能混用。rgb(43, 108 246, 0.5)这种写法整体无效浏览器会直接丢弃这条声明而且不会报错。表现就是我明明写了样式但不生效。DevTools 里失效的声明会有删除线遇到诡异的不生效先去检查这个。另一个常被忽略的点现代语法允许none关键字代替某个通道比如hsl(none 0% 50%)在动画和相对颜色里有用但除非你明确需要别乱用兼容性和可读性都吃亏。3. HSL/HSLA给人用的调色方式3.1 色相、饱和度、亮度三个旋钮的直觉HSL 把这套从机器存储的角度转到了人眼描述的角度。色相 H 是角度0 度红、120 度绿、240 度蓝走完一圈回到红。饱和度 S 是百分比0% 是灰100% 是全彩。亮度 L 也是百分比0% 是黑100% 是白50% 是最鲜艳的位置。这个模型的好处在于它是可拆解的。想做一个深浅不同的同色系按钮组只需要固定 H 和 S调整 L想做一个配色方案里的邻近色只动 H。我调色时最常用的就是这么几组数目的写法说明主色hsl(219 90% 55%)基准值hover 加深hsl(219 90% 47%)L 减 8%浅色背景hsl(219 90% 96%)L 提到 96%描边hsl(219 60% 80%)S 降下来避免刺眼禁用态hsl(219 10% 70%)S 几乎归零灰化注意亮度到 0% 和 100% 时会丢失色相信息因为黑和白没有色相。所以hsl(219 90% 100%)和hsl(0 90% 100%)渲染出来都是纯白如果你在动画里把亮度插值到 100%会看到颜色褪色的过程这通常正是想要的淡出效果。一个容易犯的错误hsl里的 S 和 L 在传统语法中必须带百分号hsl(219, 90, 55)是无效的。新语法允许纯数字按百分比解释但为兼容性还是写百分号更稳。3.2 批量生成色阶手算亮度刻度的做法做设计系统绕不开色阶一个主色要配 9 到 10 个深浅档位用于背景、边框、hover、active。手工一档档调太慢我一般写个循环批量算。核心思路是固定 H 和 S让 L 从高到低排一条曲线。function makeScale(h, s) { const steps [97, 93, 87, 79, 69, 57, 47, 38, 30, 23]; return steps.map((l, i) ({ name: ${(i 1) * 100}, value: hsl(${h} ${s}% ${l}%) })); } console.log(makeScale(219, 90)); // 100: hsl(219 90% 97%) ... 1000: hsl(219 90% 23%)这十个值不是随手写的它近似一个指数分布两端变化慢中间变化快这样每一档之间的视觉差距比较均匀。如果按等差取比如 95、85、75……浅色区看起来几乎没区别深色区又跳得太狠。这个坑我在第一次做设计令牌时踩过生成出来的色板前五档一个样后五档一个样只能重做。还有一点饱和度不能全程不变。浅色档位L 高于 90%如果保持 90% 的饱和度颜色会过于艳丽刺眼做背景很难看。所以浅色段我会把 S 降到 40% 到 60%深色段也要适当降中间段保持高饱和。这个两端收、中间放的规律是调了几十版色板后总结出来的。3.3 HSL 的局限它不是感知均匀的HSL 最大的问题是同一亮度值下不同色相的视觉亮度差异明显。hsl(60 100% 50%)的黄色看起来刺眼发亮hsl(240 100% 50%)的蓝色看起来深沉得多尽管两者的 L 都是 50%。原因是 HSL 的 L 只是 RGB 三通道的算术平均而人眼对不同波长的敏感度不同绿色最敏感蓝色最迟钝。这个偏差在单色主题里感觉不到一旦做多色系比如标签系统有红橙黄绿蓝紫六种颜色问题就暴露了所有标签用同一个 L 值黄的会跳出来蓝的会沉下去视觉上失衡。我处理过一个看板项目六个状态标签按规范用统一 L 值产品看了说为什么黄色那个总感觉是选中状态最后给黄色单独降了 8% 的 L。这也是 oklch 存在的意义。它的 L 通道经过人眼感知校正相同 L 的不同色相看起来亮度一致。如果你的项目有大量多色并列的场景早点转向 oklch 会省很多手工微调的时间。3.4 HSL 与 RGB 互转的实现有些老系统只认 RGB得自己转。HSL 转 RGB 的算法不算复杂核心是把色相角度映射到六个扇区function hslToRgb(h, s, l) { h (((h % 360) 360) % 360) / 360; // 归一化到 0-1 s Math.min(100, Math.max(0, s)) / 100; l Math.min(100, Math.max(0, l)) / 100; 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 hue2rgb t { if (t 0) t 1; if (t 1) t - 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 [hue2rgb(h 1 / 3), hue2rgb(h), hue2rgb(h - 1 / 3)] .map(v Math.round(v * 255)); } console.log(hslToRgb(219, 90, 57)); // [44, 113, 247] 附近反过来的 RGB 转 HSL 要先归一化再找最大最小通道算差值。这两个函数我建议直接抄进工具库别每次现推尤其是hue2rgb里那三个区间判断记不住顺序很容易写反写反了色相整体偏移 120 度红变绿那种。提示如果你只是要把 HSL 转成十六进制给设计稿用别自己算浏览器的getComputedStyle会把hsl()直接算成rgb()返回取一下就有了。4. 现代色彩空间oklch、lab、display-p34.1 sRGB 的天花板与广色域前面讲的 hex 和 rgb本质上都在 sRGB 这个色彩空间里打转。sRGB 是几十年前为 CRT 显示器定的一套标准覆盖了当时设备能显示的大部分颜色。但现在的手机屏、笔记本屏普遍能显示比 sRGB 更宽的范围尤其是高饱和的绿和青sRGB 根本表达不出来。这就是color(display-p3 ...)的用武之地。display-p3 的色域比 sRGB 大约宽 25%主要扩展在红绿方向。同样一句品牌绿用 sRGB 写出来偏灰用 display-p3 写出来就鲜亮得多。.green { background: rgb(0 200 100); /* sRGB 范围 */ background: color(display-p3 0 0.78 0.39); /* 更饱和 */ }代价是兼容性和一致性。不支持 display-p3 的设备会回退到上面那条能看到两条声明的效果差异。而且设计师的稿子如果是在 sRGB 环境里出的你在页面上用 P3 值会超出稿子反而不准。所以我的建议是只有当设计明确要求广色域且目标设备主要是新款手机和 Mac 时才用普通业务系统老老实实待在 sRGB 里别给自己找麻烦。顺带说个实际检验方法截屏后把图片丢进取色器如果拿到的是color(display-p3 ...)或带有超出 0-255 范围的分量说明中间某个环节用了广色域这时候和你手写的 sRGB 值对比会出现偏差别以为是代码写错了。4.2 oklch 三个参数怎么读oklch()是目前最值得学的新语法格式是oklch(L C H / alpha)L亮度0 到 1或者 0% 到 100%。0 是黑1 是白。感知均匀不同色相同 L 值看起来一样亮。C彩度0 到 0.4 左右。0 是灰数值越大越鲜艳。sRGB 色域内实际能到的上限因色相而异绿和青能到 0.3 以上蓝和紫通常到 0.2 左右就到顶了。H色相0 到 360 度。和 HSL 的角度表不完全一样oklch 的红大约在 29 度绿在 142 度蓝在 264 度。.brand { background: oklch(0.58 0.19 258); /* 一种蓝 */ color: oklch(0.98 0.01 258); /* 同色系的极浅色 */ border-color: oklch(0.58 0.19 258 / 0.3); }写顺了之后会发现它的好处调深色调浅色只动第一个数饱和度只动第二个数两者互不干扰。HSL 里你把 L 从 55% 调到 75%色相看起来会漂移oklch 基本不会。要注意的是超出色域的取值会被截断而不是报错。oklch(0.6 0.4 264)的彩度超过了 sRGB 能表示的范围浏览器会静默压到边界值你得到的颜色和理论上不一样。想避免这种情况一是用工具查目标色相的彩度上限二是先在浏览器里写一下用 DevTools 看最终计算值有没有被压。4.3 渐进增强回退写法与 supports新语法上生产回退方案得先想好。最简单的办法是写两条声明旧浏览器不认识第二条就自动跳过.btn { background: #2b6cf6; /* 兜底 */ background: oklch(0.58 0.19 258); /* 新浏览器生效 */ }这种写法的原理是 CSS 的后写优先 无效声明丢弃旧浏览器解析第二条时失败整条声明被忽略第一条生效新浏览器两条都认第二条覆盖第一条。它比supports简洁日常够用。复杂场景才需要supports比如你打算在新色彩空间基础上再做渐变、阴影等一连串效果supports (color: oklch(0 0 0)) { .card { background: linear-gradient( 140deg, oklch(0.95 0.03 258), oklch(0.88 0.08 258) ); box-shadow: 0 8px 24px oklch(0.4 0.1 258 / 0.18); } }注意supports里的条件得写一个完整的、可被解析的声明supports (color: oklch)这种不完整值测不出来会返回 false。我见过有人这么写结果新浏览器里样式全没生效排查了半天。回退策略上还有个取舍如果新旧颜色差别不大两条声明的方案就够如果差别很大比如广色域那种视觉跳变就得考虑设计上是否接受两套观感或者干脆只用其中一个。4.4 color-mix() 与相对颜色语法这两个特性是近两年我觉得最实用的两样东西。color-mix()直接在 CSS 里做颜色混合省掉了预处理器和 JS.tag { /* 品牌色与白色各半 */ background: color-mix(in oklch, var(--brand), white 50%); /* 品牌色 15% 与透明混合做一个淡背景 */ border-color: color-mix(in oklab, var(--brand), transparent 70%); }第二个参数里的百分比是第一种颜色占多少white 50%表示白色占 50%。混合空间选in oklch还是in srgb结果差别不小srgb 混合更接近传统取色器的结果oklch 混合则能保持色相不偏移做深色渐变时更干净。我一般背景混合用 oklch需要和设计稿完全对齐时用 srgb。相对颜色语法更进一步能基于一个已有颜色推导出新的.btn { --brand: oklch(0.58 0.19 258); background: var(--brand); } .btn:hover { /* 只把亮度降 0.08色相和彩度不变 */ background: oklch(from var(--brand) calc(l - 0.08) c h); } .btn:active { background: oklch(from var(--brand) calc(l - 0.15) c h); }from var(--brand)把原色解构成 l、c、h 三个变量然后你可以对每个分量做运算。这套写法做状态色几乎是零成本再也不用维护--brand、--brand-hover、--brand-active三个变量。兼容性上它比 oklch 本身还要新一点用的时候同样套一层回退。5. 落到项目里变量、暗色模式和对比度5.1 设计令牌把颜色收进 CSS 变量颜色值写在具体规则里是我最反对的做法没有之一。理由很直接主题要换、设计要迭代、暗色模式要加任何一处硬编码都会变成未来的债。正确姿势是先把颜色收进一层变量规则里只引用变量。:root { /* 基础色板只在这里出现具体值 */ --blue-100: hsl(219 90% 96%); --blue-300: hsl(219 90% 80%); --blue-500: hsl(219 90% 55%); --blue-700: hsl(219 90% 42%); --gray-100: hsl(220 12% 96%); --gray-500: hsl(220 8% 52%); --gray-900: hsl(220 15% 14%); /* 语义层业务代码只用这一层 */ --color-bg: var(--gray-100); --color-text: var(--gray-900); --color-primary: var(--blue-500); --color-primary-hover: var(--blue-700); --color-border: var(--gray-300, hsl(220 12% 88%)); }这套分层是必须的色板层负责有哪些颜色语义层负责这些颜色用在哪。业务样式只写background: var(--color-primary)换主题时改语义层的映射就行一行都不用碰业务代码。提示CSS 变量里存的是文本片段不是颜色对象。--brand: 219 90% 55%这种只存数值的写法很常见用的时候拼成hsl(var(--brand))好处是可以用calc()改其中一个分量坏处是变量本身不是一个合法颜色不能直接用于background新人接手容易懵。团队里选一种别混用。5.2 暗色模式的三种做法暗色模式说白了就是同一套语义换一套值。实现方式有三条路各有取舍。第一种媒体查询重定义变量最传统也最稳。:root { --color-bg: #ffffff; --color-text: #1a1a1a; } media (prefers-color-scheme: dark) { :root { --color-bg: #121212; --color-text: #e8e8e8; } }优点是不用管状态用户系统设置什么就跟着走缺点是用户没法手动覆盖。第二种靠>:root[data-themedark] { --color-bg: #121212; --color-text: #e8e8e8; }灵活性最高但要多写一点 JS 处理首次加载的闪烁问题。第三种light-dark()函数最省事一行一个颜色。:root { color-scheme: light dark; --color-bg: light-dark(#ffffff, #121212); --color-text: light-dark(#1a1a1a, #e8e8e8); }前提是color-scheme得声明否则函数不生效。这套写法简洁但做不了手动切换它只跟随系统所以实际项目里我常用第一种做默认、第二种做覆盖两者叠加。暗色模式有个大坑不能简单把亮色值反相。纯白底配纯黑字在亮色下很舒服反过来纯黑底配纯白字会发光看久了累。业界比较公认的做法是背景用#121212这种近黑而不是纯黑文字用#e0e0e0而不是纯白同时把饱和度整体压低一点鲜艳色在暗底上会显得刺眼。5.3 对比度计算WCAG 4.5:1 到底怎么算无障碍规范里常说的 4.5:1是正文文字与背景的最低对比度大字号18pt 以上或 14pt 加粗放宽到 3:1UI 组件边界也是 3:1。这个数字不是拍脑袋定的背后是相对亮度的算法。计算分三步把每个通道归一化到 0-1做 gamma 线性化再按人眼敏感度加权求和。function srgbToLinear(c) { return c 0.03928 ? c / 12.92 : Math.pow((c 0.055) / 1.055, 2.4); } function luminance([r, g, b]) { const [R, G, B] [r, g, b].map(v srgbToLinear(v / 255)); return 0.2126 * R 0.7152 * G 0.0722 * B; } function contrastRatio(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); } // 白底黑字 console.log(contrastRatio([0, 0, 0], [255, 255, 255]).toFixed(2)); // 21.00那个0.05是规范里的经验常数作用是防止纯黑背景导致分母为零。加权系数 0.2126 / 0.7152 / 0.0722 来自人眼对红绿蓝的敏感度绿色占的比重最大这也解释了为什么绿色文字在白底上最难看清。实际用的时候不用手算浏览器的 DevTools 里选中文字元素颜色预览旁边就会显示对比度数值和 AA/AAA 标记。但知道算法有好处比如你要写一个自动检查脚本或者做主题生成器时得在代码里约束。我踩过的坑是只检查了主文字色忘了 placeholder 和禁用态文字。这两类颜色通常做得很淡对比度往往只有 2:1 出头。规范上 placeholder 也属于需要满足对比度的文本禁用态则有豁免。做验收时把这两处单独拎出来看能省一轮返工。5.4 打印与高对比度模式的兜底还有两个容易被忽略的场景。打印浏览器默认不打印背景色和背景图如果你的信息依赖背景色块区分比如状态标签打出来会变成一片白。解决办法是print-color-adjust: exact但要慎用大面积深色背景打印出来是一场灾难既费墨又看不清。我的做法是只给关键的小色块加这个属性同时针对打印单独写一套简化样式。media print { .tag { print-color-adjust: exact; border: 1px solid #999; } .hero { background: none; color: #000; } }高对比度模式系统开启强制对比度后部分浏览器会覆盖你写的背景色和边框色只保留文字色。这时候如果某个元素完全靠背景色传达状态信息就丢了。稳妥做法是给状态补一层边框或图标别把语义全押在颜色上。media (forced-colors: active) { .badge { border: 1px solid ButtonText; forced-color-adjust: none; /* 谨慎使用通常不需要 */ } }注意forced-color-adjust: none会关掉系统的高对比度适配除非你非常确定自己在做什么否则别加。我见过有人为了保住设计效果全局加了这个属性结果对需要高对比度的用户来说页面直接不可用。6. 踩坑实录与排查速查表6.1 九个高频错误下面这些是我和自己团队实际遇到过的按出现频率排。八位十六进制的 alpha 当成亮度。#ff000033不是暗红是很淡的红。想调暗就动 RGB 分量别动最后两位。透明度用opacity代替 alpha。opacity: 0.5会让整个元素连同子元素一起透明包括文字和图标background: rgb(0 0 0 / 0.5)只影响背景。做蒙层时用前者没问题做按钮背景时用前者会把文字也搞淡。渐变到transparent出现灰边。原因前面说过transparent是透明黑。改成同色零 alpha。HSL 的 S/L 忘写百分号。传统语法下hsl(219, 90, 55)无效整条声明被丢弃。CSS 变量拼接时多了空格或分号。--brand: #2b6cf6;存的是 #2b6cf6带前导空格某些拼接场景会出问题更常见的是--rgb: 43, 108, 246;然后写rgb(var(--rgb), 0.5)在现代语法下这是非法混合。十六进制缩写看错。#f60是#ff6600不是#f06000规则是每位重复不是补零。同名颜色大小写混写。#FF0000和#ff0000等价不报错但代码风格检查会抓团队里统一小写。半透明叠加以为能到不透明。两层 50% 叠加是 75%不是 100%。改了变量没生效。CSS 变量的层叠规则和普通属性一样后面定义的同名变量会覆盖前面的尤其:root和组件内同时定义时容易打架。用 DevTools 的 Computed 面板看最终解析值是最快的。6.2 排查流程与工具用法颜色不生效这类问题我的排查顺序是固定的四步基本能在两分钟内定位。第一步看 DevTools 里声明有没有被划掉。被划掉说明语法无效或优先级不够。如果是删除线且带有黄色警告三角多半是值不合法比如混用了新旧语法或者写了个拼错的命名色。第二步看 Computed 面板的最终值。这里显示的是浏览器实际计算的rgb()值。如果你写的是 hsl 或变量这里会全部还原成最终的 rgb能直接确认变量有没有正确解析。第三步用取色器吸管验证真实渲染。有些颜色被父元素的 opacity、混合模式、滤镜影响看代码看不出来直接吸一下屏幕上的像素最准。DevTools 的取色器还能切到不同色彩空间显示检查是不是广色域的问题。第四步孤立复现。新建一个空白页面只保留出问题的那一条颜色规则如果正常说明是被别的样式影响了如果不正常说明这条规则本身有问题。这个动作看着笨但比在原地猜快得多。另外几个实用技巧在 DevTools 的颜色方块上点一下会弹出取色面板里面能直接切 hex / rgb / hsl / hwb切换时会自动转换控制台里用getComputedStyle(document.body).color可以直接拿到计算值想要批量检查页面里所有硬编码的颜色可以在 Elements 面板里搜#虽然会误伤 id 选择器但比肉眼扫快。6.3 速查表格最后放两张表写代码时扫一眼能省不少事。语法速查需求推荐写法备注固定品牌色#2b6cf6或var(--brand)优先变量半透明蒙层rgb(0 0 0 / 0.45)别用 opacity同色系色阶hsl(219 90% 55%)固定 H、S 调 L多色等亮度oklch(0.6 0.15 258)感知均匀基于主色推导状态oklch(from var(--brand) calc(l - 0.08) c h)需回退两色混合color-mix(in oklch, var(--a), white 30%)30% 是 white 占比跟随文字色currentColor边框、图标常用透明transparent或rgb(0 0 0 / 0)前者等于后者对比度自查内容类型最低对比度常见错误正文文字4.5:1灰字压浅灰底只有 3:1大字号18pt / 14pt 粗3:1按正文字号算过度保守输入框 placeholder4.5:1普遍做得太淡按钮边框、图标3:1只测文字不测 UI禁用态豁免但仍建议保持可辨识写完这些我最想强调的还是那一条颜色值本身不难难的是管理。一个项目里散落着几百个硬编码色值改一次主题就是一次冒险把它们收进变量、分成色板和语义两层、状态色用相对颜色推导后续所有调整都变成改一两个数字的事。我在最近一个项目里把颜色统一收口之后暗色模式的上线时间从预估的三天压到了半天代码量少了将近两百行。至于 oklch 这类新语法别急着全量替换先在新增模块里试用把回退方案写好用一段时间再决定要不要铺开——工具是拿来省事的不是拿来炫技的。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。