构建主题切换组件

简要介绍了如何构建自适应且易于访问的主题切换组件。

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

演示按钮尺寸增大,以便于查看

如果您更喜欢视频,可以观看此帖子的 YouTube 版本:

概览

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

在构建此功能时,需要考虑多个 Web 工程方面的问题。例如,浏览器应尽快了解偏好设置,以防止出现网页颜色闪烁,并且控件需要先与系统同步,然后允许客户端存储的例外情况。

此图显示了 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 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>

已向 SVG 元素添加 aria-hidden,以便屏幕阅读器知道要忽略该元素,因为该元素被标记为展示元素。这非常适合用于视觉装饰,例如按钮内的图标。除了元素上必需的 viewBox 属性之外,还要添加高度和宽度,原因与图片应获取内嵌尺寸类似。

太阳

太阳图标,光线已淡出,并有一个亮粉色箭头指向中心圆圈。

太阳图形由一个圆和一些线条组成,SVG 恰好有相应的形状。通过将 cxcy 属性设置为 12(视口大小 [24] 的一半),使 <circle> 居中,然后指定半径 (r) 为 6,从而设置大小。

<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>

这次,填充的值不再是 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 未加载,会怎么样?

屏幕截图:一个普通的浏览器按钮,里面有一个太阳图标。

最好测试一下 SVG,看看在 CSS 未加载的情况下,结果是否不会过大或导致布局问题。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: 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 Propseasings 部分:

@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 开发者工具的动画面板中,您可以找到动画过渡的时间轴。您可以检查整个动画、元素和缓动时间的时长。

从浅色到深色的过渡
从深色到浅色的过渡

月球

月亮的亮暗位置已设置完毕,在 --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()
  })
更改 macOS 系统偏好设置会更改主题切换状态

总结

现在您已经知道我是如何做到的,那么您会怎么做呢?🙂

让我们丰富方法,了解在网络上构建内容的所有方式。 制作演示视频,通过 Twitter 向我发送链接,我会将其添加到下方的社区混音部分!

社区混音作品