您是否曾经在观看别人炫耀 JavaScript 技巧时,突然想到“我可以用 CSS 做到这一点!”?观看 Dag-Inge Aas 和 Ida Aalen 在 CSSconf EU 2018 上的演讲 时,我正是有了这种感觉。
他们来自挪威,在那里 WCAG 无障碍性 不仅仅是一种良好的实践,而是法律的实际要求(加油,挪威!)。在开发一项允许用户为其主要产品选择颜色主题的功能时,他们面临着一个挑战:根据所选容器的背景颜色自动调整字体颜色。如果背景色较暗,则最好使用白色文本以保持 WCAG 对比度合规。但是,如果选择浅色背景怎么办?文本既难以阅读又无法满足无障碍性要求。
他们采用了优雅的方法,使用 “color” npm 包 解决了这个问题,并在过程中添加了条件边框和自动辅助颜色生成。
但这是一个 JavaScript 解决方案。以下是我的纯 CSS 替代方案。
挑战
这是我设定的目标
- 根据背景颜色将字体
color
更改为黑色或白色 - 对边框应用相同的逻辑,使用背景基本颜色的较暗变体来提高按钮可见性,仅当背景非常浅色时
- 自动生成辅助的,旋转 60° 色相的颜色

使用 HSL 颜色和 CSS 变量
我认为最简单的方法是使用 HSL 颜色。将背景声明设置为 HSL,其中每个参数都是一个 CSS 自定义属性,这为确定亮度并将其用作条件语句的基础提供了一种非常简单的方法。
:root {
--hue: 220;
--sat: 100;
--light: 81;
}
.btn {
background: hsl(var(--hue), calc(var(--sat) * 1%), calc(var(--light) * 1%));
}
这应该允许我们通过更改变量并运行 if/else 语句来更改前景色,从而在运行时将背景切换到任何我们想要的颜色。
除了……我们在 CSS 中没有 if/else 语句……或者有吗?
引入 CSS 条件语句
自从引入 CSS 变量以来,我们也获得了与之搭配的条件语句。或者说是类似的东西。
此技巧基于这样一个事实:某些 CSS 参数会被限制在最小值和最大值之间。例如,考虑不透明度。有效范围是 0 到 1,因此我们通常将其保持在该范围内。但是,我们也可以声明 2、3 或 1000 的不透明度,它将被限制为 1 并按此解释。类似地,我们甚至可以声明负的不透明度值,并使其限制为 0。
.something {
opacity: -2; /* resolves to 0, transparent */
opacity: -1; /* resolves to 0, transparent */
opacity: 2; /*resolves to 1, fully opaque */
opacity: 100; /* resolves to 1, fully opaque */
}
将技巧应用于字体颜色声明
HSL 颜色声明的亮度参数的行为方式类似,将任何负值限制为 0(无论色相和饱和度如何,这都会导致黑色),而任何超过 100% 的值都会限制为 100%(这始终为白色)。
因此,我们可以将颜色声明为 HSL,从亮度参数中减去所需的阈值,然后乘以 100% 以强制其超过某个限制(低于零或高于 100%)。由于我们需要负结果解析为白色,而正结果解析为黑色,因此我们还必须将其反转,将结果乘以 -1。
:root {
--light: 80;
/* the threshold at which colors are considered "light." Range: integers from 0 to 100,
recommended 50 - 70 */
--threshold: 60;
}
.btn {
/* Any lightness value below the threshold will result in white, any above will result in black */
--switch: calc((var(--light) - var(--threshold)) * -100%);
color: hsl(0, 0%, var(--switch));
}
让我们回顾一下这段代码:从亮度 80 开始,并考虑阈值 60,减法结果为 20,乘以 -100% 后,结果为 -2000%,限制为 0%。我们的背景比阈值亮,因此我们认为它是浅色并应用黑色文本。
如果我们将 --light
变量设置为 20,减法结果将为 -40,乘以 -100% 将变为 4000%,限制为 100%。我们的 light 变量低于阈值,因此我们认为它是一个“暗”背景,并应用白色文本以保持高对比度。
生成条件边框
当元素的背景变得太浅时,它很容易与白色背景混淆。我们可能有一个按钮,但甚至没有注意到它。为了在非常浅的颜色上提供更好的 UI,我们可以根据相同的背景颜色设置边框,只是颜色更深。

为了实现这一点,我们可以使用相同的技术,但将其应用于 HSLA 声明的 alpha 通道。这样,我们就可以根据需要调整颜色,然后使其完全透明或完全不透明。
:root {
/* (...) */
--light: 85;
--border-threshold: 80;
}
.btn {
/* sets the border-color as a 30% darker shade of the same color*/
--border-light: calc(var(--light) * 0.7%);
--border-alpha: calc((var(--light) - var(--border-threshold)) * 10);
border: .1em solid hsla(var(--hue), calc(var(--sat) * 1%), var(--border-light), var(--border-alpha));
}
假设色相为 0,饱和度为 100%,上述代码将提供一个完全不透明的纯红色边框,如果背景亮度高于 80,则为原始亮度的 70%,或者如果背景亮度较暗,则为完全透明的边框(因此,根本没有边框)。
设置辅助的,旋转 60° 色相的颜色
这可能是最简单的挑战之一。有两种可能的方法可以解决它
- filter: hue-rotate(60): 这是首先想到的,但它不是最佳解决方案,因为它会影响子元素的颜色。如有必要,可以通过相反的旋转将其反转。
- HSL 色相 + 60: 另一个选项是获取色相变量并向其添加 60。由于色相参数没有在 360 处限制行为,而是循环(就像任何 CSS
<angle>
类型一样),因此它应该可以毫无问题地工作。例如,400deg=40deg
、480deg=120deg
等。
考虑到这一点,我们可以为辅助颜色元素添加一个修饰符类,该类将 60 添加到色相值。由于 CSS 中无法自修改变量(即,不存在 --hue: calc(var(--hue) + 60)
这样的东西),因此我们可以为我们的基本样式添加一个新的辅助变量来进行色相操作,并在背景和边框声明中使用它。
.btn {
/* (...) */
--h: var(--hue);
background: hsl(var(--h), calc(var(--sat) * 1%), calc(var(--light) * 1%));
border:.1em solid hsla(var(--h), calc(var(--sat) * 1%), var(--border-light), var(--border-alpha));
}
然后在修饰符中重新声明其值
.btn--secondary {
--h: calc(var(--hue) + 60);
}
这种方法的优点在于,由于 CSS 自定义属性的作用域,它将自动调整所有计算以适应新的色相并将其应用于属性。
就是这样。将所有内容放在一起,以下是我对所有三个挑战的纯 CSS 解决方案。这应该可以完美运行,并且可以避免包含外部 JavaScript 库。
查看 CodePen 上 Facundo Corradini (@facundocorradini) 编写的笔 CSS 自动切换字体颜色取决于元素背景……失败。
但事实并非如此。某些色相会变得非常麻烦(尤其是黄色和青色),因为尽管具有相同的亮度值,但它们显示的比其他色相(例如红色和蓝色)亮得多。因此,某些颜色被视为暗色并被赋予白色文本,尽管它们非常亮。
CSS 到底发生了什么?
引入感知亮度
我相信你们中的许多人可能早就注意到了,但对于我们其他人来说,事实证明我们感知到的亮度 与 HSL 亮度并不相同。幸运的是,我们有一些方法可以权衡色相亮度并调整我们的代码,使其也对色相做出响应。
为此,我们需要考虑三种原色的感知亮度,为每种颜色提供一个系数,对应于人眼感知到的亮度或暗度。这通常称为亮度。
有几种方法可以实现这一点。某些方法在特定情况下比其他方法更好,但没有一种方法是 100% 完美的。因此,我选择了两种最流行的方法,它们足够好
- sRGB 亮度 (ITU Rec. 709):L = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255
- W3C 方法 (工作草案): L = (红色 * 0.299 + 绿色 * 0.587 + 蓝色 * 0.114) / 255
实现亮度校正计算
采用亮度校正方法的第一个明显含义是,我们不能使用 HSL,因为 CSS 没有原生方法来访问 HSL 声明的 RGB 值。
因此,我们需要切换到 RBG 声明作为背景,根据我们选择的方法计算亮度,并在我们的前景颜色声明中使用它,该声明仍然可以(并且将)是 HSL。
:root {
/* theme color variables to use in RGB declarations */
--red: 200;
--green: 60;
--blue: 255;
/* the threshold at which colors are considered "light".
Range: decimals from 0 to 1, recommended 0.5 - 0.6 */
--threshold: 0.5;
/* the threshold at which a darker border will be applied.
Range: decimals from 0 to 1, recommended 0.8+ */
--border-threshold: 0.8;
}
.btn {
/* sets the background for the base class */
background: rgb(var(--red), var(--green), var(--blue));
/* calculates perceived lightness using the sRGB Luma method
Luma = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255 */
--r: calc(var(--red) * 0.2126);
--g: calc(var(--green) * 0.7152);
--b: calc(var(--blue) * 0.0722);
--sum: calc(var(--r) + var(--g) + var(--b));
--perceived-lightness: calc(var(--sum) / 255);
/* shows either white or black color depending on perceived darkness */
color: hsl(0, 0%, calc((var(--perceived-lightness) - var(--threshold)) * -10000000%));
}
对于条件边框,我们需要将声明转换为 RGBA,然后再次使用 alpha 通道使其完全透明或完全不透明。与之前几乎相同,只是运行 RGBA 而不是 HSLA。较暗的阴影是通过从每个通道减去 50 获得的。
iOS 上的 WebKit 存在一个非常奇怪的错误,如果在简写边框声明的 RGBA 参数中使用变量,它将显示黑色边框而不是相应的颜色。解决方案是使用完整格式声明边框。
感谢Joel指出这个错误!
.btn {
/* (...) */
/* applies a darker border if the lightness is higher than the border threshold */
--border-alpha: calc((var(--perceived-lightness) - var(--border-threshold)) * 100);
border-width: .2em;
border-style: solid;
border-color: rgba(calc(var(--red) - 50), calc(var(--green) - 50), calc(var(--blue) - 50), var(--border-alpha));
}
由于我们丢失了最初的 HSL 背景声明,因此需要通过色相旋转来获取我们的辅助主题颜色。
.btn--secondary {
filter: hue-rotate(60deg);
}
这不是最好的方法。除了将色相旋转应用于之前讨论的潜在子元素外,它还意味着辅助元素上切换到黑色/白色和边框可见性将取决于主元素的色相,而不是它本身。但据我所知,JavaScript 实现存在相同的问题,因此我将其称为足够接近。
就是这样,这次是永久性的。
查看 CodePen 根据元素背景自动切换 WCAG 对比度字体颜色,作者为 Facundo Corradini (@facundocorradini),发布在 CodePen 上。
一个纯 CSS 解决方案,可以实现与原始 JavaScript 方法相同的效果,但显著减少了代码量。
浏览器支持
由于使用了 CSS 变量,因此 IE 不支持。Edge 没有我们贯穿始终使用的限制行为。它会看到声明,认为它是无意义的,并像处理任何损坏/未知规则一样将其完全丢弃。其他所有主要浏览器都应该可以工作。
这太棒了,我找了很久的东西。感谢 Facundo 分享。
谢谢!很高兴能帮上忙 :)
太棒了,简直太棒了。
哇,关于在 css 中使用颜色的精彩文章。
杰出的工作。
我发现 iOS 上的边框始终为黑色(或透明黑色),因此 WebKit 在某个地方出了问题。
嘿,Joel!感谢你的提醒。我将仔细研究一下,看看是否能找到解决方法。看起来它似乎没有获取 RGBA 声明的 RGB 值。也许除法会产生小数值,而 WebKit 需要整数?……我将检查一下并回复 :)
事实证明这是一个非常奇怪的错误,看起来 WebKit 在简写版本的边框中使用时,不接受 RGBA 声明中的变量,加上我最初想到的整数与小数的问题。
无论如何,解决方案是将边框声明更改为完整格式并通过减法创建较暗的阴影。
感谢你的意见!我将在文章中更新修改后的声明。
当然很有趣,很难看到任何可以使用此功能的场景。
信不信由你,我同意你的观点。这更像是一个突破极限的想法,而不是我们在生产中实际使用的想法。尽管如此,在开发或阅读此类文章时,总有一些东西可以吸取。也许我们可以将不透明度和 alpha 值的限制用于其他计算,也许条件边框具有一定的价值。
此特定实现仅旨在在为产品创建自定义主题时提供实时预览,但我们也可以在根目录下设置所有这些奇怪的计算,然后通过修改 –r、–g 和 –b 值来为元素着色背景,这应该可以使我们的整个网站自动适应前景颜色到背景。当将组件插入不同的环境中,或当状态更改导致背景切换时,这可能很有用。
伟大的工作,A+。
谢谢 Kellen!
很棒,但是色盲用户怎么办?感知亮度可能仍然会对他们造成困扰。
谢谢 Chris!这是一个合理的担忧,特别是考虑到最常见的色盲形式是红绿色盲,并且他们在感知亮度计算中具有完全不同的权重。
我必须承认,尽管在撰写这篇文章时投入了相当多的时间来研究这个问题,但我并不真正知道答案。
如果你有关于色盲如何与感知亮度相关的可靠来源,我将渴望仔细看看。
不幸的是,我还没有找到任何关于这个主题的论文。但是,我确实启用了 Chrome 浏览器“单色”扩展程序 (https://chrome.google.com/webstore/detail/monochrome/nahpfoghefabhigpjfhgliafalpejclf) 检查了演示,它似乎没问题。
在最后一个 CodePen 演示中。只有绿色的变化会影响字体颜色,这是意外吗?
不,这只是 RGB 颜色的工作原理。如果没有混合一些绿色,背景根本不会变得足够亮。这是一个非常奇怪且不直观的颜色系统。
初始值可能会给你这种感觉,但将绿色设置为更高的值,然后更改红色或蓝色,你将看到文本也会对这些更改做出反应。
这是我读过的关于 CSS 变量以及如何真正使用它们的最酷的事情之一!非常有启发性。感谢分享。
谢谢!CSS 变量确实很棒。这项技术几乎是一种技巧,但最近发布的 CSS 值和单位模块级别 4 的公开草案引入了 min()、max() 和 clamp() 数学表达式,因此当浏览器开始实现它们时,我们将能够在任何地方使用此类计算,以一种不太像技巧的方式 :)
以下方法是否有效
嗨,Anthony,
这是一个非常巧妙的方法,它肯定会在某些情况下有效,主要的限制在于对标记的侵入性有多大。有时我真的很希望我们能够直接定位元素的文本节点……
对比过滤器需要另一个零,否则它会在某些颜色(例如 #F60)上产生灰色。