构建主题切换组件

简要介绍如何构建易于使用的自适应主题切换组件。

在这篇博文中,我想与大家分享关于构建深色和浅色主题切换组件的方法。试用演示版

演示按钮大小,以便轻松查看

如果你更喜欢视频,可以参考本博文的 YouTube 版本:

概览

网站可以提供用于控制配色方案的设置,而不是完全依赖于系统偏好设置。这意味着用户可能会在系统偏好设置以外的模式下浏览。例如,用户的系统采用浅色主题,但用户更希望网站在深色主题中显示。

构建此功能时,有一些网络工程需要注意。例如,应尽快让浏览器知晓此偏好设置,以防止页面颜色闪烁,并且该控件需要先与系统同步,然后再允许客户端存储的异常。

该图显示了 JavaScript 网页加载和文档互动事件的预览,总体展示了设置主题有 4 条路径

Markup

您应为切换开关使用 <button>,这样您便可以从浏览器提供的互动事件和功能(例如点击事件和可聚焦性)中受益。

按钮

按钮需要一个用于 CSS 的类以及一个用于 JavaScript 的 ID。此外,由于按钮内容是图标而不是文本,因此请添加 title 属性以提供有关按钮用途的信息。最后,添加 [aria-label] 以保存图标按钮的状态,以便屏幕阅读器可以将主题状态分享给视障人士。

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto"
>
  …
</button>

aria-labelaria-live礼让

如需向屏幕阅读器指示应读出对 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 可以非常方便地包含形状。通过将 cxcy 属性设置为 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>

此外,遮罩属性会指向 SVG 元素的 ID,您接下来将创建该 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>
包含 3 个垂直层的图形,有助于显示遮罩的工作原理。顶层是带有黑色圆圈的白色正方形。中间层是太阳图标。
最下面一层被标记为结果,它在顶层黑色圆圈处显示了带有一个凹口的太阳图标。

使用 SVG 的蒙版功能强大,能够使用白色和黑色来移除或包含其他图形的某些部分。只需将圆形形状移入和移出蒙版区域,即可使用具有 SVG 蒙版的月亮 <circle> 遮挡太阳图标。

如果 CSS 无法加载,会发生什么情况?

包含太阳图标的普通浏览器按钮的屏幕截图。

最好像未加载 CSS 一样测试 SVG,以确保结果不会过大或不会造成布局问题。SVG 上的内嵌高度和宽度属性加上对 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 则包含视觉和动画方面。您可以在此处美化图标,使其更加生动

浅色主题

ALT_TEXT_HERE

若要使动画从 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);
    }
  }
}

深色主题

ALT_TEXT_HERE

月球样式需要移除日光,放大太阳圈并移动圆形蒙版。

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

请注意,深色主题没有颜色变化或过渡。父按钮组件拥有颜色,这些颜色已经可以在深色和浅色环境中自适应。过渡信息应位于用户的动作偏好设置媒体查询之后。

动画

该按钮应可正常使用且有状态,但此时没有转换。以下部分都是关于定义转换方式和转换内容。

共享媒体查询和导入加/减速选项

为了轻松地在用户的操作系统动作偏好设置后面添加转场效果和动画,PostCSS 插件自定义媒体支持使用媒体查询变量的 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: 1) {
        transform: translateX(0);
        cx: 17;
        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 文档解析状态。浏览器还不知道“#theme-toggle”按钮,因为 <head> 标记尚未完全解析。不过,浏览器确实有 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()
  })
更改 MacOS 系统偏好设置会更改主题切换状态

总结

现在你已经知道我是怎么做的,希望你怎么办 ‽ 🙂?

下面,我们就来介绍一下我们的方法多样化,并了解在 Web 上构建网站的所有方法。 只需创建一个演示,点击 tweet me 链接,我就会将其添加到下方的“社区混剪”部分中!

社区混剪作品