现代 CSS

给网站添加暗黑模式指南

特别声明:如果您喜欢小站的内容,可以点击申请会员进行全站阅读。如果您对付费阅读有任何建议或想法,欢迎发送邮件至: airenliao@gmail.com!或添加QQ:874472854(^_^)

给网站添加暗黑模式是随着macOS中的暗黑模式(Dark Mode)出现之后的一个热门话题,今年上半年在《如何使用CSS实现暗黑模式和高亮模式的切换》一文中就和大家一起聊了聊怎么通过CSS来实现暗黑模式和高亮模式切换。事实上,社区上有关于这方面的讨论也很多,都在围绕着怎么给网站添加暗黑模式。今天在这篇文章再次和大家一起聊聊这个已久的话题,不同的是,这篇文章将和大家从不同的角度来聊怎么给网站添加暗黑模式。感兴趣的同学,请继续往下阅读。

暗黑模式是系统级别的

所谓的暗黑模式并不是现在才有的,这个事实已经存在很久了。如果你很早就接触过电脑的话,你可能会发现你使用过的电脑屏幕经历过好几个过程,看起来会像下面这样:

是不是觉得既熟悉又陌生。既然如此,为什么今年会成为设计或者说Web端的一个热点呢?

其实这一切都应该归功于Apple公司,在macOS系统中提出了darklight两种视觉模式,即暗色(dark高亮(light两种皮肤,而且这两种皮肤是系统级别的,我们可以通过系统上的切换,让整个电脑上只要支持dark/light模式的应用都可以轻易切换。

那么为什么要从系统级别去做这个事情呢?这是有原因的。系统面对的用户群体中朋部分人士在身体上存有一定的缺陷,比如说色盲的用户群体。也就是说,这种暗黑模式或者高亮模式对于有色盲的用户群体是非常友好的。既然如此,为了让自己的Web网站或者Web应用能向系统级别靠齐,就有了网站级别的暗黑模式。

你可能在很多网站的右上角看到了一个提供暗黑和高亮模式的切换按钮。

暗黑模式实现原理

给Web网站或者Web应用添加暗黑模式的基本原理我想大家应该很清楚,事实上也非常的简单。

正如上图所示,给同一个Web网站或Web应用提供多套皮肤,用户根据自己的喜欢进行选择。那么给网站添加暗黑模式是同一个原理,就是给网站同时提供两套皮肤,即theme1.csstheme2.css

早期我们可能会借助于JavaScript脚本,根据用户的选择在一个<link />标签上进行两个主题文件(即.css文件)切换来实现:

<!-- HTML -->
<link type="text/css" rel="stylesheet" media="all" href="../theme1.css" id="theme_css" />

// Script
document.getElementById('buttonID').addEventListener('click', function(){                     document.getElementById('theme_css').href = '../theme2.css'; 
})

这可能是一种比较古老的实现方案。也是大家最为熟悉的方案。

CSS实现暗黑模式切换

时至今日,给Web网站或Web应用程序实现暗黑模式已有多种模式。可以是纯CSS的方式,也可以是CSS和JavaScript结合的模式。那么接下来,我们来看看具体的实现方式。

媒体查询prefers-color-scheme

CSS有一个特别强大的特性,那就是媒体查询@media,CSS的@media规则可以用于有条件地将样式应用于文档以及其他各种上下文和语言,如HTML和JavaScript。在W3C的Media Queries Level 5引入了“用户首选媒体特性”,即Web网站或应用程序检测用户显示内容的首先方式的方法

比如prefers-reduced-motion这个媒体查询就可以检测页面上的动画,假设设备开启了“Reduce motion”选项,就可以通过该媒体查询选项让页面上的元素是否具有动效:

如果用户开启减少动效的喜好,那么就不要在元素上使用动效:

@media (prefers-reduced-motion: reduce) {
    button {
        animation: none;
    }
}

如果用户没有在系统级别设置该选项的话,可以像下面这样让按钮有动效:

@media (prefers-reduced-motion: no-preference) {
    button {
        animation: vibrate 0.3s linear infinite both;
    }
}

Web上的其他具有动效的元素都可以像上面之样使用, 上面只是用button为例。

如果Web网站有很多元素具有动效的话,还可以将所有与动效相关的CSS放在一个独立的文件中,然后通过linkmedia属性来加载:

<link rel="stylesheet" href="animations.css" media="(prefers-reduced-motion: no-preference)" />

为了说明JavaScript如何控制preferences-reduced-motion。这里假设你在项目中使用了Web Animation API。当用户开启了偏好设置,CSS规则会被浏览器动态触发,这样一来我五一需要自己监听变化,然后手动停止与动画相关的东西:

const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
mediaQuery.addEventListener('change', () => {
    console.log(mediaQuery.media, mediaQuery.matches);
    // ...
});

如果你有强迫症,强迫减少网站上所有有动效停下来,还可以像下面这样的简单粗暴的操作:

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.001s !important;
        transition-duration: 0.001s !important;
    }
}

有关于prefers-reduced-motion更详细的介绍可以阅读@Thomas Steiner的博文《Move Ya! Or maybe, don't, if the user prefers-reduced-motion!》。

似乎上面的内容偏离了我们今天要聊的主题,大家不用着急。只不过是拿prefers-reduced-motion这个媒体查询来抛砖引玉而以。在CSS中通过媒体查询的prefers-color-scheme特性和prefers-reduced-motion类似,不同是,该特性是用于检测用户是否要求页面使用light还是dark主题。该媒体查询常见的值有:

  • no-preference:表示用户未指定操作系统主题。其作为 布尔值 时以false输出
  • light:表示用户的操作系统是浅色主题(light
  • dark:表示用户的操作系统是深色主题(dark

也就是说,通过prefers-color-scheme媒体查询要让暗黑模式(dark)开启深色系主题,可以像下面这样使用:

@media (prefers-color-scheme: dark) {
    :root {
        --background-color: #111416
        --text-color: #ccc;
        --link-color: #f96;
    }
}

当然在非dark模式下,你的样式可能像下面这样:

:root {
    --background-color: #fff;
    --text-color: #333;
    --link-color: #b52;
}

body {
    background-color: var(--background-color);
    color: var(--text-color);
}

a {
    color: var(--link-color);
}

注意上面提供的示例代码仅仅是最基本的颜色配置方案,但也可以说完成了近90%的工作。但细节决定成败。如果要让你的Web网站或应用程序在lightdark模式切换下能有较好的效果,还需要注意其他的一些细节,比如说imgsvg等元素的细节处理。有关于细节方面的,稍后我们会再讨论,暂且不表。

正如上面的示例所示,我们是通过CSS的媒体查询特性来检测dark模式,即通过检查媒体查询是否首选。那么颜色方案是否匹配还需要检查当前浏览器是否支持dark模式。

我们可以像下面这样来检测浏览器是否支持dark模式:

if (window.matchMedia('(prefers-color-scheme)').media !== 'not all') {
    console.log('浏览器支持dark模式!(^_^)');
}

至于哪些浏览已支持prefers-color-scheme特性,我们可以通过Caniuse来查询:

前面的示例简单的向大家演示了如何给Web网站或应用程序设置暗黑模式。但有很多细节我们需要去注意。

在一个应用中只dark(暗色系)和light(亮色系)只能是二选一,永远不可能两者共存。为什么要提这个呢?我们从加载策略来做衡量。如果我们不管三七二十一,直接将所有样式(普通样式、亮色系样式和暗色系样式)都用一个.css文件加载的话会强迫用户在关键的渲染路径中下载CSS(包括你不想要的模式代码也加载进来了)。为此,为了优化加载速度和给用户提供更好的体验,我们可以将CSS分成三个部分,以延迟非关键的CSS:

  • style.css:网站上普通样式(通用样式)
  • dark.css:暗色系所需样式规则
  • light.css:亮色系所需样式规则

其中dark.csslight.css可以通过<link media="" />有条件的加载。加上并不是所有浏览器都已支持prefers-color-scheme特性,所以我们在加载通用样式style.css规则的基础上动态默认加载light.css。即,不支持该特性的浏览器会按下面的顺序加载CSS:style.css ➜ light.css ➜ dark.css;如果支持该特性的浏览器则会按下面的顺序加载CSS:style.css ➜ dark.css ➜ light.css。具体的代码如下:

<!-- Script -->
<script>
    if (window.matchMedia('(prefers-color-scheme: dark)').media === 'not all') {
        document.documentElement.style.display = 'none';
        document.head.insertAdjacentHTML(
            'beforeend',
            '<link rel="stylesheet" href="/light.css" onload="document.documentElement.style.display = \'\'">'
        );
    }
</script>

<!-- HTML -->
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/dark.css" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" href="/light.css" media="(prefers-color-scheme: no-preference), (prefers-color-scheme: light)">

按照该规则,前面的CSS示例代码,我们就可以按下面这样的文件来划分:

// dark.css
:root {
    --background-color: #111416
    --text-color: #ccc;
    --link-color: #f96;
}

// light.css
:root {
    --background-color: #fff;
    --text-color: #333;
    --link-color: #b52;
}

// style.css
body {
    background-color: var(--background-color);
    color: var(--text-color);
}

a {
    color: var(--link-color);
}

这里使用了CSS自定义属性,该示例再次向大家演示了CSS自定义属性的强大之处。如果你从未接触过CSS自定义属性的话,建议你花时间阅读有关于这方面的教程。可以猛击这里获取

CSS的新特性color-scheme

CSS Color Adjustment Module Level 1提供了另一个新属性color-scheme。该特性会告诉浏览器该应用的颜色主题和允许用户代理的特殊变体样式表,而且它还可以让Web中的部分区域的渲染在darklight之间切换,比如让浏览器渲染渲染的表单域是个黑色背景和高亮文本。

来看一个简单的示例代码:

/* dark.css */
:root {
    --color: rgb(250, 250, 250);
    --background-color: rgb(5, 5, 5);
    --link-color: rgb(0, 188, 212);
    --main-headline-color: rgb(233, 30, 99);
    --accent-background-color: rgb(0, 188, 212);
    --accent-color: rgb(5, 5, 5);
}

/* light.css */
:root {
    --color: rgb(5, 5, 5);
    --background-color: rgb(250, 250, 250);
    --link-color: rgb(0, 0, 238);
    --main-headline-color: rgb(0, 0, 192);
    --accent-background-color: rgb(0, 0, 238);
    --accent-color: rgb(250, 250, 250);
}

/* style.css */
:root {
    color-scheme: light dark;
}

body {
    color: var(--color);
    background-color: var(--background-color);
}

同样的,还可以HTML的meta标签来设置:

<meta name="supported-color-schemes" content="light dark">

到目前为止,支持color-scheme的浏览器还较少

俗话说,百闻不如一见,虽然在《如何使用CSS实现暗黑模式和高亮模式的切换》一文中向大家展示了多个相关案例,这里向大家展示一个由@Thomas Steiner提供的案例

上面这个案例和以往提供的案例有所不同。该案例按前面所讲的分成三个独立的样式文件:style.cssdark.csslight.css。尝试切换暗黑模式并重新加载页面,你会发现不匹配的样式文件仍然会被加载,只是优先级有所差异,这样做它们就不会与站点当前所需的资源竞争。

当网站是在light模式下,样式文件加载优先级是style.css ➜ light.css ➜ dark.css,即 dark.css权重最低(Lowest):

当网站在dark模式下,样式文件加载优先级是style.css ➜ dark.css ➜ light.css,即 light.css权重最低(Lowest):

当浏览器不支持prefers-color-scheme而且设置light为默认模式,那么样式加载优先级会和高亮模式一样:

特别声明,上面三图截图来自于《Hello darkness, my old friend》一文。

上面示例还做了另一个细节上的优化。和其他媒体查询更改一样,可以通过JavaScript的订阅来更改暗黑模式。比如可以动态更改页面的favicon或更改<meta name="theme-color">来决定Chrome中URL栏的颜色。代码并不复杂:

const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkModeMediaQuery.addListener((e) => {
    const darkModeOn = e.matches;
    console.log(`Dark mode is ${darkModeOn ? 'on' : 'off'}.`);
});

CSS混合模式来助攻

上面我们看到的都是原生CSS处理暗黑模式的技术方案。事实上我们还可以通过CSS Hack来实现,采用CSS的filter和CSS的混合模式mix-blend-mode

下面这个示例就是CSSfilter实现的暗黑模式:

关键代码很少:

.theme-dark { 
    filter: invert(100) hue-rotate(180deg); 
} 

.theme-dark img { 
    filter: invert(100) hue-rotate(180deg); 
}

另外还可以使用CSS的mix-blend-mode来实现:

.dark-mode-screen { 
    width: 100vw; 
    height: 100vh; 
    position: fixed; 
    top: 0; 
    left: 0; 
    background: white; 
    mix-blend-mode: difference; 
}

如果你想让页面中部分元素忽略mix-blend-mode:difference带来的影响,可以使用isolation: isolate

.twitter-logo,
.emoji {
    isolation: isolate; 
}

效果会类似下图这样:

有关于这方面的详细介绍可以阅读@thoughtspile的《How to create a dark theme without breaking things: learning with the Yandex Mail team》和的@wgao19《Night Mode with Mix Blend Mode: Difference》。

其他细节

根据前面的内容去操作,不管是使用CSS的媒体查询prefers-color-scheme、新特性color-scheme还是借助CSS的滤镜filter或混合模式mix-blend-mode都可以轻易的给Web网站或应用程序添加暗黑模式。

注意,很多同学有一个小误区,认为filtermix-blend-mode只能用于图片,事实上并非如此,他可以运用于Web的各种元素上。

那么掌握了实现暗黑模式的技术方案就能做出好的效果吗?并非如此,其中还是有很多细节需要我们注意。比如颜色的配置、Web媒体(图片、Icon等)和可访问性等方面的处理都值得我们去推敲。

颜色的配置

在给Web网站或Web应用设计暗黑模式的时候,你千万不要钻到死胡同里。暗黒模式并不仅仅是黑(black白(white之间的切换。你想像一下,在一个深夜密不透光的地方,用你的肉眼注视着一块高亮的屏幕,时间久了,你会有什么样的一个感觉:

正确的做法是应该为你的品牌色系提供一个暗色系版本,如果不奏效的话,可以根据需要在黑色和灰色之间选择一个平均颜色。比如说,Web的背景颜色是black(#000)(或者接近#000)的话,建议你前景色(比如文本颜色)取值为rgb(250, 250, 250)(或者靠近这个颜色值)。这样才能让你的整体效果不至于亮瞎用户的眼睛。比如下面这样的一个效果:

如果你实在拿不准配色是否合理(Web安全颜色),你可以借助在线工具,比如Contrast Checker:

该工具是根据WCAG 2.0 guidelines for contrast accessibility标准来做的。比如下面图所展示的效果就是一个较好的效果:

除了借助工具来检测之外,浏览器的插件也是把利器,可以借助浏览器有关于Accessibility相关的插件来做检测,就比如小站,检测出来的结果令人汗颜:

事实上,很多网站都存在这样的缺陷:

图片处理

在暗黑模式下,图片的处理也是非常重要的。它们可能会直接影响用户的体验,太亮的图像可能会让用户感到困惑和不舒服。而且有人做过这方面相应的调查,大多数被调查的人在暗黑模式下更喜欢亮度低的图像。比如下面这张图:

左侧是暗黑模式下效果,右侧是在高亮模式下效果

为了能更用户更好的体验,这里提倡在不同模式下给用户展示不同效果的图像,但并非说在不同的模式下引入不同的图片。就目前CSS的技术,我们在同一图像源下可以很好的对图像做处理。比如,粗暴一点的使用CSS的opacity,温柔一点的使用CSS的混合模式mix-blend-mode(如果是背景的话则用background-blend-mode)或 filter

// 粗暴模式
@media (prefers-color-scheme: dark) {
    img {
        opacity: 0.65;
    }

    img:hover {
        opacity: 1;
    }
}

// 温柔模式
@media (prefers-color-scheme: dark) {
    img {
        filter: brightness(.8) contrast(1.2);
    }
}

不过这里有一个细节需要注意,我们引入的图像源有可能是.svg的矢量图,如果希望给矢量图(更多是Icon)一个不同于位图(更多是图像,照片)的重新着色处理。那么我们可以通过属性选择器和伪类选择器将.svg过滤掉:

@media (prefers-color-scheme: dark) {
    img:not([src*=".svg"]) {
        filter: brightness(.8) contrast(1.2);
    }
}

如果你希望给用户更多的选择的话,我们可以将CSS自定义属性和JavaScript结合起来,可以让用户根据自己的喜好去做调节。这里还是拿图片的处理为例吧:

// dark.css
:root {
    --brightness: brightness(.8);
    --contrast: contrast(1.2);
    --image-filter: var(--brightness) var(--contrast);
}

// JS
document.documentElement.style.setProperty('--image-filter', value);

也可以参考下面这个Demo,使用filter修改图片效果:

当然,如果你是位追求极致的同学,希望在暗黑模下给用户提供最好的图像,而不是随便修改图片的亮度或饱和度;但又不希望因加载图片资源过多而影响整体的性能(甚至不希望因为自己的原因过渡浪费流量)。如果真的是这样的话,你可以由设计师为暗黑模式下提供特定的图片,然后<picture>元素一起使用。可以在<picture><source>根据媒体属性的设置加载所需要的图片资源:

<picture>
    <source media="prefers-color-scheme: dark" srcset="./dark.webp" />
    <source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcset="./light.webp" />
    <img src="./light.png" alt="map" />
</picture>

暗黑模式下会加载dark.webp图片,在高亮模式或者不支持prefers-color-scheme的浏览器中会加载light.webp图片,不支持<picture>的浏览器会加载light.png图片。你将看到的效果可能如下:

图标的处理

刚才提到过,很有可能你Web网站或Web应用程序中有很多Icon图标用的是SVG图标。在暗黑模式下,同样要对Icon图标做相应的处理。这里来看两种情景。

先来看第一种,那就是.svg文件和其他格式的图像一相通过<img>标签引入。由于该Icon很有可能是纯色的,因此在暗黑模式下,我们可以通过filter来做dark/light之间的切换:

/* dark.css */
:root {
    --icon-filter: invert(100%);
    --icon-filter_hover: invert(40%);
}

img[src*=".svg"] {
    filter: var(--icon-filter);
}

/* light.css */
:root {
    --icon-filter_hover: invert(60%);
}

/* style.css */
img[src*=".svg"]:hover {
    filter: var(--icon-filter_hover);
}

如果你还想调整成其他的颜色,还可以像下面这个Demo来操作,增加filter的属性值选项:

该方法和处理图像的方法是类似的。接下来我们再来看第二种方式。使用的Icon图标很有可能是内联的SVG,针对这样的场景,我们可以使用CSS的currentColor属性。currentColor最大的特性就是可以根据color的值来决定元素的颜色,而对于SVG绘制的Icon图标,主要由pathcirclerect这样的元素构成,这些元素可以通过fillstroke来决定填充色和描边色。换句话说,我们在使用内联SVG时,将SVG中用到fillstroke的属性值都强制设置成currentColor,就像下面这样:

<svg xmlns="http://www.w3.org/2000/svg"    
    viewbox="0 0 24 24" fill="none"
    stroke="currentColor" stroke-width="2"
    stroke-linecap="round" stroke-linejoin="round"
>
    <circle cx="12" cy="12" r="10"/>
    <circle cx="12" cy="12" r="4"/>
    <line x1="21.17" y1="8" x2="12" y2="8"/>
    <line x1="3.95" y1="6.06" x2="8.54" y2="14"/>
    <line x1="10.88" y1="21.94" x2="15.46" y2="14"/>
</svg>    

另外在媒体查询中设置:

@media (prefers-color-scheme: dark) {
    :root {
        --background-color: #111416
        --text-color: #ccc;
        --link-color: #f96;
    }

    svg {
        color: var(--text-color)
    }
}

如果你分成多个文件的话,可能会像下面这样的:

/* dark.css */
:root {
    --color: rgb(250, 250, 250);
    --background-color: rgb(5, 5, 5);
    --link-color: rgb(0, 188, 212);
    --main-headline-color: rgb(233, 30, 99);
    --accent-background-color: rgb(0, 188, 212);
    --accent-color: rgb(5, 5, 5);
}

/* light.css */
:root {
    --color: rgb(5, 5, 5);
    --background-color: rgb(250, 250, 250);
    --link-color: rgb(0, 0, 238);
    --main-headline-color: rgb(0, 0, 192);
    --accent-background-color: rgb(0, 0, 238);
    --accent-color: rgb(250, 250, 250);
}

/* style.css */
:root {
    color-scheme: light dark;
}

body {
    color: var(--color);
    background-color: var(--background-color);
}

svg {
    color: var(--color);
}

让切换有一个过渡效果

熟悉CSS的同学都应该记得,CSS的transition可以让元素在两个状态的切换过程中有一个平滑过渡的效果,以至于不会那么生硬:

而我们聊的dark/light两模式之间的切换刚好稳合transition。加上dark/light两模式之间的切换就是colorbackground-color属性值的切换。为了让整个切换过程有一个过渡效果,我们可以把transition加上来。比如:

body {
    --duration: 0.5s;
    --timing: ease;

    color: var(--color);
    background-color: var(--background-color);

    transition: color var(--duration) var(--timing), background-color var(--duration) var(--timing);
}

JavaScript实现dark/light模式切换

如果你不依任CSS,或者说希望让自己的Web网站或Web应用程序都具备dark/light模式的切换,那么可以通过JavaScript来实现。因为dark/light模式的切换说到底就是两套主题的切换。当然,你可以让该JS的能力更为强大一些,不仅仅是对.css文件的切换,粗暴简单的实现网站换肤这样的一个功能。或许你可以这样做:

  • 在网站上提供相应的切换按钮(比如一个tab选项卡,也可以是一个radio按钮),方便用户自行选择
  • 该JS可以对系统级别做监听,如果用户从系统级别开启了暗黑模式,那么就把样式文件切换到dark.css
  • 还可以根据时间来做一个dark/light模式的切换,比如说白天采用light模式,晚上使用dark模式
  • 和CSS实现dark/light模式切换一样,还可以在JS中加上transition效果,让模式在切换的过程有一个过渡效果

为了节约篇幅,这里就不把JavaScript代码贴出来了,感兴趣的话可以看看@Koos Looijesteijn的《A guide to implemen­ting dark modes on websites》一文。文章中详细的根据上面几个过程,向大家展示了对应的JavaScript代码。如果你不愿阅读文章的话,可以点击这里直接阅读源代码

不过就我个人而言,我不太推荐使用JavaScript方案,这可能和我自己的信条有关系:

在Web端能用CSS实现的绝不借助JavaScript

浏览器配置

下面有一个简单的示例:

在该示例的页面上没有提供任何切换按钮给用户做选择。主要目的是希望页面能根据系统级别的设置来决定采用什么主题。假设你的系统默认就开启了暗黑模式。但你在浏览器看到的效果也不一定是dark模式下的效果。这主要是因为浏览器对prefers-color-scheme支持有一定的差异。不过我们可以对浏览器做一些设置,让页面能正常的跟着系统设置做出正确的渲染。

如果你使用Firefox,可以在地址栏中输入about:config,然后鼠标右键点击选择“新建(New)” → “整数(Integer)”,新建整数ui.systemUsesDarkTheme,并且将其值设置为1

如果你使用的是Safari浏览器,可以使用它自带的工具来查看效果:

最终在支持的浏览器下看到的效果如下:

除此之外,还可以给浏览器安装插件。比如@CHRIS HOFFMAN在他的《How to Enable Dark Mode for Google Chrome》文章中就详细的介绍了怎么在Chrome浏览器安装Dark Reader插件,让Web页面具有暗黑模式浏览效果:

小结

上面我们通过不同的方式向大家阐述和演示了如何实现黑暗模式和高亮模式切换的解决方案。有粗暴简单的方式,原始的切换样式表的方式,还有采用一些新的CSS特性,比如CSS自定义属性,新的媒体查询特性,还有神奇的滤镜和混合模式。而且这些解决方案中既有CSS和JavaScript的混合解决方案,也有纯CSS的解决方案,甚至还有原生系统和浏览器通信的解决方案。还是那句老话,不管哪种解决方案或者技术手段,都有自己的利弊,没有最好,只有最适合的使用场景。在实际使用的时候,应该具体问题具体分析。

扩展阅读

如果你想更深入了解这方面的知识,建议你花一些时间阅读下面相关的教程:

返回顶部