背景色深浅难倒英雄汉,咋用纯CSS搞定文字黑或白?

3,121字
13–20 分钟
in

现在的网页动不动就要搞个动态背景,让文字颜色自动适配背景深浅。这活儿看着简单,真上手就发现全是坑。什么WCAG对比度公式,什么APCA新标准,光是一堆数学公式就能把人绕晕。但咱前端打工人不能怂,今天就把这问题给盘明白。

目录

面对一个可以随意变换的背景色,怎么用纯CSS代码让文字在深色背景上自动变白,浅色背景上自动变黑?直接上硬核方案:利用OKLCH色彩空间的感知亮度特性,通过一行 oklch(from ... round(1.21 - L) 0 0) 计算公式,就能实现智能切换。这招比折腾WCAG那一大堆RGB换算要清爽得多,而且更贴合最新的APCA视觉对比标准。文章里会手把手拆解这串代码的来历,顺便教一招还能把黑色换成任意主题色的骚操作。

WCAG 与 APCA

WCAG 2.2 里头的对比度算法,本质上是拿两个RGB颜色的亮度值做除法。亮度计算得先把RGB数值经过一堆2.4次幂的复杂变换,最终得到一个0到1之间的亮度值。然后套用公式 (L1+0.05)/(L2+0.05),算出来的比值就是对比度。这个算法虽然严谨,但写成CSS代码得堆出一长串pow函数,可读性基本为零。

APCA 是下一代对比度标准,更贴近人眼对颜色亮度的真实感知。它不再用简单的亮度除法,而是引入了更复杂的数学模型。这玩意儿虽然更准,但要是硬写成CSS,代码复杂度直接起飞,维护起来能让人头秃。

OKLCH 色彩空间

OKLCH 是个好东西,它的L通道(感知亮度)数值直接反映了颜色在肉眼看来有多亮。这个数值从0到1,0是最暗,1是最亮。为啥要请它出场?因为有了这个现成的亮度值,就不用自己吭哧吭哧去算RGB的亮度了,直接拿来用,省了老鼻子劲儿。

举个例子,纯红色在OKLCH里头的L值大概是0.6左右,纯黄色能达到0.9。这意味着黄色背景上黑字比白字更清楚,红色背景上白字更占优。

找到黑白切换点

得先搞清楚一个事儿:L值到多少的时候,白色文字就比黑色文字更清楚?直觉可能觉得是0.5,但实测发现根本不是那回事儿。很多颜色就算L值到了0.7,黑色文字依然糊成一团。

搞了个小测试,用APCA标准去测不同L值下黑白文字的对比度。测试结果挺有意思:

  • L值在0.72以上的颜色,黑色文字的APCA对比度稳稳超过白色文字
  • L值在0.65以下的颜色,白色文字全面占优
  • 中间那一段0.65到0.72,黑白对比度差距不大,都能看清

所以阈值就定在0.72,L值大于0.72用黑色,小于等于0.72用白色。

核心公式推导

目标很明确:把L值大于0.72的变成黑色,小于等于0.72的变成白色。在CSS里用 round() 函数就能实现这个开关效果。

round(1.21 - L, 0) 这串代码干的事儿:先算1.21减去L值。如果L是0.72,1.21减0.72等于0.49,四舍五入取整后是0。如果L是0.71,1.21减0.71等于0.5,四舍五入后是1。

最终 round() 输出的是0或1。把0和1作为亮度值塞进 oklch() 里:

  • 当结果是0时,生成的颜色是 oklch(0 0 0),也就是黑色
  • 当结果是1时,生成的颜色是 oklch(1 0 0),也就是白色

完整的写法就是这样:

color: oklch(from var(--bg-color) round(1.21 - L) 0 0);

这行代码的意思:从背景色里提取L值,经过上面那串计算,得出0或1作为新的亮度,色度和色调都归零,最终输出纯黑或纯白。

实战操作流程

第一步:准备好背景色

背景色得是个CSS颜色变量,方便动态切换。比如:

.element {
  --bg-color: #ff6b6b;  /* 随便来个珊瑚橙 */
  background-color: var(--bg-color);
}

第二步:套上文字颜色

直接用刚才的公式设置文字颜色:

.element {
  --bg-color: #ff6b6b;
  background-color: var(--bg-color);
  color: oklch(from var(--bg-color) round(1.21 - L) 0 0);
}

这样文字颜色就会跟着背景色走。背景深的时候L值低,算出结果是1,文字变白;背景浅的时候L值高,算出结果是0,文字变黑。

第三步:验证效果

搞几个不同的背景色试试:

  • #000000 纯黑,L值是0,公式算出1.21减0等于1.21,四舍五入取整得1,文字是白色
  • #ffffff 纯白,L值是1,公式算出1.21减1等于0.21,四舍五入得0,文字是黑色
  • #ff6b6b 珊瑚橙,在OKLCH里L值约0.7,1.21减0.7得0.51,四舍五入得1,文字是白色

第四步:应对边界情况

有些背景色的L值刚好卡在0.72附近,比如 #c0c0c0 银色。这种情况下黑白文字对比度都差不多,公式给哪个都能接受。如果觉得某个颜色不理想,可以微调公式里的1.21这个阈值参数。把数值调大一点,黑色文字出现的范围就更广;调小一点,白色文字出现的范围就更广。比如改成1.18,那L值大于0.68的就会变成黑色,更激进地倾向使用黑字。

高级玩法:切换主题色

黑和白可能不够用,有时候需要让文字在浅色背景上变成某种主题色(比如品牌蓝),深色背景上还是保持白色。这得动点脑筋,用 color-mix() 配合一些数学运算来实现。

核心思路:先用刚才的公式生成一个开关信号(0或1)。0代表应该用主题色,1代表应该用白色。然后用这个信号去混合主题色和白色。

具体步骤这样写:

.element {
  --bg-color: #ff6b6b;
  --base-color: #1e88e5;  /* 主题蓝 */
  --switch: oklch(from var(--bg-color) round(1.21 - L) 0 0);
  color: rgb(from color-mix(in srgb, var(--switch), var(--base-color)) calc(2 * r) calc(2 * g) calc(2 * b));
}

这串代码看起来有点绕,其实逻辑很直接:

color-mix(in srgb, var(--switch), var(--base-color)) 这一步把开关信号和主题色混在一起。开关信号是白色时(值为oklch(1 0 0)),混合结果等于白色和主题色的平均,也就是RGB各通道取半。开关信号是黑色时(值为oklch(0 0 0)),混合结果就是主题色本身(因为黑色和任何颜色混合,黑色不贡献颜色值)。

混合完了再乘以2,把减半的数值翻回来:

  • 如果开关信号是白色,混合结果是主题色减半,再翻倍,正好回到白色
  • 如果开关信号是黑色,混合结果是主题色,翻倍后变成了两倍主题色,但RGB通道最大值是255,超过255的会被截断,所以实际上就是主题色本身

这套玩法在Chrome和Firefox里跑得顺溜,Safari 18以下版本有点水土不服,但可以做个回退方案,用纯黑白的版本兜底。

.element {
  --bg-color: #ff6b6b;
  --base-color: #1e88e5;
  --switch: oklch(from var(--bg-color) round(1.21 - L) 0 0);
  color: oklch(from var(--bg-color) round(1.21 - L) 0 0);  /* Safari兜底 */
}
@supports (color: rgb(from color-mix(in srgb, red, red) r g b)) {
  .element {
    color: rgb(from color-mix(in srgb, var(--switch), var(--base-color)) calc(2 * r) calc(2 * g) calc(2 * b));
  }
}

这样一搞,老浏览器也能正常显示黑白色,新浏览器直接上主题色,体验拉满。