☰
HTML颜色值写对写稳:hex、HSL与oklch工程实践
2026/10/28 4:04:12 网站建设 项目流程

上周帮人看一个后台页面,设计师稿子里按钮的 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 到 100,a、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 到 255(ff等于 255)。为什么八位写法#RRGGBBAA的最后两位是 alpha?因为多出来的那个字节就存在这里。为什么不建议在 HTML 里写rgb(100%, 0%, 0%)?因为百分比和你脑子里的 0-255 刻度是两套体系,混着写极易出错。

顺带说一个实际影响:8 位色深意味着相邻两个色值之间的差异是 1/255,肉眼在大多数屏幕上分辨不出来。所以做渐变色阶时,没必要生成 100 级,8 到 12 级足够,再多是浪费。

2.2 十六进制换算与缩写规则(附转换脚本)

十六进制的转换是硬功夫,得会。规则不复杂:0-9表示 0 到 9,a-f表示 10 到 15,两位组合的值是"高位乘 16 加低位"。#ff0000拆成ff、00、00,ff是 15×16+15=255,所以是纯红。#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 不是亮度:透明度合成的真实效果

回到开头那个事故。八位十六进制里,最后两位是 alpha,00全透明,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-p3

4.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-theme="dark"] { --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:1,UI 组件边界也是 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 九个高频错误

下面这些是我和自己团队实际遇到过的,按出现频率排。

  1. 八位十六进制的 alpha 当成亮度。#ff000033不是"暗红",是很淡的红。想调暗就动 RGB 分量,别动最后两位。
  2. 透明度用opacity代替 alpha。opacity: 0.5会让整个元素连同子元素一起透明,包括文字和图标;background: rgb(0 0 0 / 0.5)只影响背景。做蒙层时用前者没问题,做按钮背景时用前者会把文字也搞淡。
  3. 渐变到transparent出现灰边。原因前面说过,transparent是透明黑。改成同色零 alpha。
  4. HSL 的 S/L 忘写百分号。传统语法下hsl(219, 90, 55)无效,整条声明被丢弃。
  5. CSS 变量拼接时多了空格或分号。--brand: #2b6cf6;存的是" #2b6cf6"带前导空格,某些拼接场景会出问题;更常见的是--rgb: 43, 108, 246;然后写rgb(var(--rgb), 0.5),在现代语法下这是非法混合。
  6. 十六进制缩写看错。#f60是#ff6600不是#f06000,规则是每位重复,不是补零。
  7. 同名颜色大小写混写。#FF0000和#ff0000等价,不报错,但代码风格检查会抓,团队里统一小写。
  8. 半透明叠加以为能到不透明。两层 50% 叠加是 75%,不是 100%。
  9. 改了变量没生效。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 这类新语法,别急着全量替换,先在新增模块里试用,把回退方案写好,用一段时间再决定要不要铺开——工具是拿来省事的,不是拿来炫技的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询