使用 CSS 切换不同背景下的字体颜色

Avatar of Facundo Corradini
Facundo Corradini 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

您是否曾经在观看别人炫耀 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° 色相的颜色

这可能是最简单的挑战之一。有两种可能的方法可以解决它

  1. filter: hue-rotate(60): 这是首先想到的,但它不是最佳解决方案,因为它会影响子元素的颜色。如有必要,可以通过相反的旋转将其反转。
  2. HSL 色相 + 60: 另一个选项是获取色相变量并向其添加 60。由于色相参数没有在 360 处限制行为,而是循环(就像任何 CSS <angle> 类型一样),因此它应该可以毫无问题地工作。例如,400deg=40deg480deg=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 没有我们贯穿始终使用的限制行为。它会看到声明,认为它是无意义的,并像处理任何损坏/未知规则一样将其完全丢弃。其他所有主要浏览器都应该可以工作。