简要介绍了如何构建自适应且易于访问的主题切换组件。
在本文中,我想分享一下构建深色和浅色主题切换组件的方法。 试用演示版。
如果您更喜欢视频,请观看此帖子的 YouTube 版本:
概览
网站可能会提供用于控制配色方案的设置,而不是完全依赖于系统偏好设置。这意味着,用户可以使用除其系统偏好设置之外的模式进行浏览。例如,用户的系统采用浅色主题,但用户希望网站以深色主题显示。
在构建此功能时,需要考虑一些 Web 工程方面的问题。例如,浏览器应尽快了解偏好设置,以防止网页颜色闪烁,并且控件需要先与系统同步,然后才能允许客户端存储的异常。
Markup
应为切换开关使用 <button>
,这样您就可以获享浏览器提供的互动事件和功能,例如点击事件和可聚焦性。
按钮
该按钮需要一个用于从 CSS 使用的方法,以及一个用于从 JavaScript 使用的方法。此外,由于按钮内容是图标而非文本,因此请添加 title 属性,以提供有关按钮用途的信息。最后,添加 [aria-label]
来存储图标按钮的状态,以便屏幕阅读器可以向视障人士分享主题的状态。
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
>
…
</button>
aria-label
和 aria-live
polite
如需向屏幕阅读器指明应读出对 aria-label
的更改,请向按钮添加 aria-live="polite"
。
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
…
</button>
添加此标记会向屏幕阅读器发出信号,以便以礼貌的方式(而不是aria-live="assertive"
)告知用户发生了哪些变化。对于此按钮,它会根据 aria-label
的状态读出“亮”或“暗”。
可缩放矢量图形 (SVG) 图标
SVG 提供了一种方法,可让您只需极少的标记即可创建高质量的可伸缩形状。与按钮互动可以触发矢量图形的新视觉状态,因此 SVG 非常适合用作图标。
以下 SVG 标记位于 <button>
内:
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
…
</svg>
aria-hidden
已添加到 SVG 元素中,因此屏幕阅读器会知道忽略它,因为它已标记为呈现性元素。这非常适合用于视觉装饰,例如按钮内的图标。除了在元素上添加必需的 viewBox
属性之外,还应出于与图片应采用内嵌大小类似的原因添加高度和宽度。
太阳
太阳图形由一个圆形和一些线条组成,SVG 非常方便地提供了这些形状。通过将 cx
和 cy
属性设置为 12(即视口大小 [24] 的一半),然后指定半径 (r
) 为 6
(用于设置大小),将 <circle>
居中显示。
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>
此外,mask 属性指向您接下来要创建的 SVG 元素的 ID,最后使用 currentColor
指定与页面文本颜色匹配的填充颜色。
阳光
接下来,在圆圈正下方的组元素 <g>
组内添加了阳光线条。
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
</svg>
这次,我们将设置每条线的描边,而不是将 fill 的值设为 currentColor
。线条和圆形构成了一轮带有光芒的太阳。
月亮
为了营造光明(太阳)与黑暗(月亮)之间顺畅过渡的错觉,月亮是使用 SVG 蒙版对太阳图标的增强。
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
…
</g>
<mask class="moon" id="moon-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
</svg>
结合使用 SVG 的蒙版功能非常强大,可让白色和黑色移除或包含其他图形的部分。只需将圆形形状移入和移出蒙版区域,太阳图标就会被带有 SVG 蒙版的月亮 <circle>
形状遮挡。
如果 CSS 无法加载,会出现什么情况?
建议您在 CSS 未加载的情况下测试 SVG,以确保结果不会过大或导致布局问题。SVG 上的内嵌 height 和 width 属性以及 currentColor
的使用为浏览器提供了最少的样式规则,以便在 CSS 无法加载时使用。这有助于针对网络波动采用良好的防御方式。
布局
主题切换器组件占用的空间很小,因此您无需使用网格或 Flexbox 来进行布局。而是使用 SVG 定位和 CSS 转换。
样式
.theme-toggle
样式
<button>
元素是图标形状和样式的容器。此父级上下文将包含要向 SVG 传递的自适应颜色和尺寸。
第一项任务是将按钮设为圆形,并移除默认按钮样式:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
}
接下来,添加一些互动样式。为鼠标用户添加了光标样式。添加了 touch-action: manipulation
,以实现快速响应的触控体验。移除 iOS 应用于按钮的半透明突出显示效果。最后,为聚焦状态轮廓留出与元素边缘之间的一些空间:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
}
按钮内的 SVG 也需要一些样式。SVG 应与按钮的大小相符,为使视觉效果柔和,请将线条末端圆角处理:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
& > svg {
inline-size: 100%;
block-size: 100%;
stroke-linecap: round;
}
}
使用 hover
媒体查询进行自适应调整
图标按钮大小为 2rem
,略小,对于鼠标用户来说没问题,但对于手指等粗糙指针来说,可能很难操作。使用悬停媒体查询指定增大尺寸,使按钮符合许多触摸大小准则。
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
太阳和月亮 SVG 样式
按钮包含主题切换器组件的互动方面,而内部的 SVG 将包含视觉和动画方面。这时,您可以美化图标并使其生动起来。
浅色主题
如需从 SVG 形状的中心进行缩放和旋转动画,请设置其 transform-origin: center center
。此处的形状使用了按钮提供的自适应颜色。月亮和太阳使用提供的按钮 var(--icon-fill)
和 var(--icon-fill-hover)
进行填充,而阳光则使用这些变量进行描边。
.sun-and-moon {
& > :is(.moon, .sun, .sun-beams) {
transform-origin: center center;
}
& > :is(.moon, .sun) {
fill: var(--icon-fill);
@nest .theme-toggle:is(:hover, :focus-visible) > & {
fill: var(--icon-fill-hover);
}
}
& > .sun-beams {
stroke: var(--icon-fill);
stroke-width: 2px;
@nest .theme-toggle:is(:hover, :focus-visible) & {
stroke: var(--icon-fill-hover);
}
}
}
深色主题
月亮样式需要移除阳光线、放大太阳圆圈并移动圆形遮罩。
.sun-and-moon {
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
}
& > .sun-beams {
opacity: 0;
}
& > .moon > circle {
transform: translateX(-7px);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
}
}
}
}
请注意,深色主题没有颜色变化或过渡。父级按钮组件拥有颜色,这些颜色已在深色和浅色背景中进行自适应。转换信息应位于用户的动作偏好媒体查询之后。
动画
此时,按钮应处于可正常使用且具有状态的状态,但没有转场效果。以下部分将介绍如何定义转换方式和转换内容。
共享媒体查询和导入缓动
为了轻松地在用户的操作系统动作偏好设置后面放置转场效果和动画,PostCSS 插件 Custom Media 支持使用草拟的 CSS 规范(适用于媒体查询变量)语法:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
.sun {
transition: transform .5s var(--ease-elastic-3);
}
}
如需使用独特且易用的 CSS 缓动效果,请导入 Open Props 的缓动效果部分:
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
transition: transform .5s var(--ease-elastic-3);
}
太阳
太阳的转换效果会比月亮更活泼,我们可以通过弹跳缓动来实现此效果。阳光在旋转时应略微弹跳,太阳中心在缩放时也应略微弹跳。
默认(浅色主题)样式用于定义转换,深色主题样式用于定义向浅色主题的转换自定义:
.sun-and-moon {
@media (--motionOK) {
& > .sun {
transition: transform .5s var(--ease-elastic-3);
}
& > .sun-beams {
transition:
transform .5s var(--ease-elastic-4),
opacity .5s var(--ease-3)
;
}
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
transition-timing-function: var(--ease-3);
transition-duration: .25s;
}
& > .sun-beams {
transform: rotateZ(-25deg);
transition-duration: .15s;
}
}
}
}
在 Chrome 开发者工具的 Animation 面板中,您可以找到动画过渡的时间轴。您可以检查总动画的时长、元素和缓动时间。
月亮
月亮亮度和深色位置已设置完毕,请在 --motionOK
媒体查询中添加转换样式,以便在遵循用户的动作偏好设置的同时使其生动起来。
在实现流畅的转换时,延迟时间和时长至关重要。 例如,如果太阳过早被遮挡,过渡效果就不会给人精心设计或轻松愉快的感觉,而是混乱无序。
.sun-and-moon {
@media (--motionOK) {
& .moon > circle {
transform: translateX(-7px);
transition: transform .25s var(--ease-out-5);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
transition: cx .25s var(--ease-out-5);
}
}
@nest [data-theme="dark"] & {
& > .moon > circle {
transition-delay: .25s;
transition-duration: .5s;
}
}
}
}
首选减少动画效果
在大多数 GUI 挑战中,我会尽量保留一些动画(例如不透明度交叉淡出),以便那些更喜欢减少动作的用户使用。不过,使用即时状态更改后,此组件看起来更好了。
JavaScript
此组件中的 JavaScript 有很多工作要做,从管理屏幕阅读器的 ARIA 信息到从本地存储空间获取和设置值。
网页加载体验
请务必确保网页加载时不会出现颜色闪烁。如果采用深色配色方案的用户表示他们更喜欢此组件的浅色,然后重新加载页面,则页面最初会显示深色,然后闪烁为浅色。为防止这种情况,需要运行少量阻塞 JavaScript,目的是尽早设置 HTML 属性 data-theme
。
<script src="./theme-toggle.js"></script>
为此,系统会先在文档 <head>
中加载纯 <script>
标记,然后再加载任何 CSS 或 <body>
标记。当浏览器遇到这样的未标记脚本时,会运行该代码,并在 HTML 的其余部分之前执行该代码。谨慎使用此阻塞时刻,可以在主 CSS 绘制页面之前设置 HTML 属性,从而防止闪烁或颜色变化。
JavaScript 会先在本地存储空间中查找用户的偏好设置,如果在存储空间中找不到任何内容,则会回退以检查系统偏好设置:
const storageKey = 'theme-preference'
const getColorPreference = () => {
if (localStorage.getItem(storageKey))
return localStorage.getItem(storageKey)
else
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
接下来,系统会解析用于在本地存储空间中设置用户偏好的函数:
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
后跟一个函数,用于使用偏好设置修改文档。
const reflectPreference = () => {
document.firstElementChild
.setAttribute('data-theme', theme.value)
document
.querySelector('#theme-toggle')
?.setAttribute('aria-label', theme.value)
}
此时需要注意的重要事项是 HTML 文档解析状态。由于 <head>
标记尚未完全解析,因此浏览器尚不了解“#theme-toggle”按钮。不过,该浏览器确实有一个 document.firstElementChild
(也称为 <html>
标记)。该函数会尝试同时设置这两者以保持同步,但在首次运行时只能设置 HTML 代码。querySelector
最初不会找到任何内容,并且可选链接运算符可确保在未找到它且尝试调用 setAttribute 函数时不会出现语法错误。
接下来,系统会立即调用该函数 reflectPreference()
,以便 HTML 文档设置其 data-theme
属性:
reflectPreference()
该按钮仍需要该属性,因此请等待网页加载事件,然后才能安全地查询、添加监听器并设置以下属性:
window.onload = () => {
// set on load so screen readers can get the latest value on the button
reflectPreference()
// now this script can find and listen for clicks on the control
document
.querySelector('#theme-toggle')
.addEventListener('click', onClick)
}
切换体验
点击该按钮后,需要在 JavaScript 内存和文档中交换主题。需要检查当前主题值,并决定其新状态。设置新状态后,保存并更新文档:
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
与系统同步
此主题切换的独特之处在于,它会随着系统偏好设置的更改而同步。如果用户在页面和此组件可见时更改了系统偏好设置,主题切换开关将更改为与新的用户偏好设置相匹配,就像用户在系统切换时同时与主题切换开关互动一样。
您可以使用 JavaScript 和监听媒体查询更改的 matchMedia
事件来实现此目的:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
总结
现在您已经知道我是如何解决的,您会怎么做? 🙂?
让我们多元化我们的方法,了解在 Web 上构建的所有方式。 制作一个演示版,在推特上向我发送链接,我会将其添加到下方的社区混剪部分!
社区混剪作品
- Codepen with Vue 中的 @NathanG
- Codepen 上的 @ShadowShahriar
- 将 @tomayac 用作自定义元素
- @bramus,使用原生 JavaScript
- @JoshWComeau(使用 react)