构建开关组件

关于如何构建响应式且易于访问的开关组件的基础概述。

在这篇文章中,我想分享关于构建开关组件的一些想法。 试用演示

演示

如果您喜欢视频,这里是这篇文章的 YouTube 版本

概述

开关的功能类似于复选框,但明确表示布尔值的开启和关闭状态。

此演示对大多数功能使用 <input type="checkbox" role="switch">,其优势在于无需 CSS 或 JavaScript 即可完全正常工作且易于访问。加载 CSS 可以支持从右到左的语言、垂直方向、动画等。加载 JavaScript 使开关可拖动且具有实际触感。

自定义属性

以下变量表示开关的各个部分及其选项。作为顶级类,.gui-switch 包含组件子项中使用的自定义属性,以及集中自定义的入口点。

轨道

长度 (--track-size)、内边距和两种颜色

.gui-switch {
  --track-size: calc(var(--thumb-size) * 2);
  --track-padding: 2px;

  --track-inactive: hsl(80 0% 80%);
  --track-active: hsl(80 60% 45%);

  --track-color-inactive: var(--track-inactive);
  --track-color-active: var(--track-active);

  @media (prefers-color-scheme: dark) {
    --track-inactive: hsl(80 0% 35%);
    --track-active: hsl(80 60% 60%);
  }
}

滑块

大小、背景颜色和交互高亮颜色

.gui-switch {
  --thumb-size: 2rem;
  --thumb: hsl(0 0% 100%);
  --thumb-highlight: hsl(0 0% 0% / 25%);

  --thumb-color: var(--thumb);
  --thumb-color-highlight: var(--thumb-highlight);

  @media (prefers-color-scheme: dark) {
    --thumb: hsl(0 0% 5%);
    --thumb-highlight: hsl(0 0% 100% / 25%);
  }
}

减少动态效果

为了添加清晰的别名并减少重复,可以将减少动态效果偏好用户媒体查询放入自定义属性中,并结合基于 Media Queries 5 中的草案规范PostCSS 插件

@custom-media --motionOK (prefers-reduced-motion: no-preference);

标记

我选择使用 <label> 包装我的 <input type="checkbox" role="switch"> 元素,将它们的关系捆绑在一起,以避免复选框和标签关联的歧义,同时让用户能够与标签交互以切换输入。

A
natural, unstyled label and checkbox.

<label for="switch" class="gui-switch">
  Label text
  <input type="checkbox" role="switch" id="switch">
</label>

<input type="checkbox"> 预先构建了 API状态。浏览器管理 checked 属性和 输入事件,例如 oninputonchanged

布局

Flexbox网格自定义属性 在维护此组件的样式方面至关重要。它们集中值,为原本含义模糊的计算或区域命名,并启用小型自定义属性 API 以实现轻松的组件自定义。

.gui-switch

开关的顶级布局是 flexbox。类 .gui-switch 包含子项用于计算其布局的私有和公共自定义属性。

Flexbox DevTools overlaying a horizontal label and switch, showing their layout
distribution of space.

.gui-switch {
  display: flex;
  align-items: center;
  gap: 2ch;
  justify-content: space-between;
}

扩展和修改 flexbox 布局就像更改任何 flexbox 布局一样。例如,将标签放在开关上方或下方,或更改 flex-direction

Flexbox DevTools overlaying a vertical label and switch.

<label for="light-switch" class="gui-switch" style="flex-direction: column">
  Default
  <input type="checkbox" role="switch" id="light-switch">
</label>

轨道

复选框输入被样式化为开关轨道,方法是移除其正常的 appearance: checkbox 并提供其自身的大小

Grid DevTools overlaying the switch track, showing the named grid track
areas with the name 'track'.

.gui-switch > input {
  appearance: none;

  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  padding: var(--track-padding);

  flex-shrink: 0;
  display: grid;
  align-items: center;
  grid: [track] 1fr / [track] 1fr;
}

轨道还为滑块创建了一个 1x1 的单单元格网格轨道区域以供声明。

滑块

样式 appearance: none 还移除了浏览器提供的可视复选标记。此组件使用 伪元素 和输入上的 :checked 伪类 来替换此可视指示器。

滑块是附加到 input[type="checkbox"] 的伪元素子项,并通过声明网格区域 track 而堆叠在轨道顶部而不是下方

DevTools showing the pseudo-element thumb as positioned inside a CSS grid.

.gui-switch > input::before {
  content: "";
  grid-area: track;
  inline-size: var(--thumb-size);
  block-size: var(--thumb-size);
}

样式

自定义属性使开关组件功能多样,能够适应配色方案、从右到左的语言和动态效果偏好。

A side by side comparison of the light and dark theme for the switch and its
states.

触摸交互样式

在移动设备上,浏览器会为标签和输入添加点击高亮和文本选择功能。这些功能对这个开关所需的样式和视觉交互反馈产生了负面影响。使用几行 CSS,我可以移除这些效果并添加我自己的 cursor: pointer 样式

.gui-switch {
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

移除这些样式并不总是明智之举,因为它们可能是宝贵的视觉交互反馈。如果移除它们,请务必提供自定义替代方案。

轨道

此元素的样式主要与其形状和颜色有关,这些样式通过 级联 从父元素 .gui-switch 访问。

The switch variants with custom track sizes and colors.

.gui-switch > input {
  appearance: none;
  border: none;
  outline-offset: 5px;
  box-sizing: content-box;

  padding: var(--track-padding);
  background: var(--track-color-inactive);
  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  border-radius: var(--track-size);
}

开关轨道的各种自定义选项来自四个自定义属性。添加了 border: none,因为 appearance: none 不会移除所有浏览器上复选框的边框。

滑块

滑块元素已在正确的 track 上,但需要圆形样式

.gui-switch > input::before {
  background: var(--thumb-color);
  border-radius: 50%;
}

DevTools shown highlighting the circle thumb pseudo-element.

交互

使用自定义属性为交互做准备,交互将显示悬停高亮和滑块位置变化。在转换动态效果或悬停高亮样式之前,还会检查用户的偏好

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

滑块位置

自定义属性为在轨道中定位滑块提供了单一来源机制。我们可以使用轨道和滑块大小进行计算,以使滑块保持适当的偏移并在轨道内:0%100%

input 元素拥有位置变量 --thumb-position,滑块伪元素将其用作 translateX 位置

.gui-switch > input {
  --thumb-position: 0%;
}

.gui-switch > input::before {
  transform: translateX(var(--thumb-position));
}

现在我们可以自由地从 CSS 和复选框元素上提供的伪类更改 --thumb-position。由于我们之前在此元素上有条件地设置了 transition: transform var(--thumb-transition-duration) ease,因此这些更改在更改时可能会产生动画效果

/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
}

/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
}

我认为这种解耦的编排效果很好。滑块元素仅关注一种样式,即 translateX 位置。输入可以管理所有复杂性和计算。

垂直方向

支持是通过修饰符类 -vertical 完成的,该类使用 CSS 转换向 input 元素添加旋转。

但是,3D 旋转元素不会更改组件的整体高度,这可能会打乱块布局。使用 --track-size--track-padding 变量来解决此问题。计算垂直按钮按预期在布局中流动所需的最小空间量

.gui-switch.-vertical {
  min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));

  & > input {
    transform: rotate(-90deg);
  }
}

(RTL) 从右到左

我的 CSS 好友 Elad Schecter 和我一起原型设计了一个 使用 CSS 转换处理从右到左语言的滑出式侧边菜单,方法是翻转单个变量。我们这样做是因为 CSS 中没有逻辑属性转换,而且可能永远也不会有。Elad 有一个很棒的想法,即使用自定义属性值来反转百分比,以便对我们自己的逻辑转换的自定义逻辑进行单位置管理。我在此开关中使用了相同的技术,我认为效果很好

.gui-switch {
  --isLTR: 1;

  &:dir(rtl) {
    --isLTR: -1;
  }
}

名为 --isLTR 的自定义属性最初持有值 1,这意味着它是 true,因为我们的布局默认是从左到右的。然后,使用 CSS 伪类 :dir(),当组件位于从右到左的布局中时,该值设置为 -1

通过在转换内部的 calc() 中使用 --isLTR 来使其生效

.gui-switch.-vertical > input {
  transform: rotate(-90deg);
  transform: rotate(calc(90deg * var(--isLTR) * -1));
}

现在,垂直开关的旋转考虑了从右到左的布局所需的相反侧位置。

还需要更新滑块伪元素上的 translateX 转换,以考虑相反侧的要求

.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
  --thumb-position: calc(
   ((var(--track-size) / 2) - (var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

虽然这种方法无法解决与逻辑 CSS 转换等概念相关的所有需求,但它确实为许多用例提供了一些 DRY 原则。

状态

使用内置的 input[type="checkbox"] 如果不处理它可以处于的各种状态(:checked:disabled:indeterminate:hover)将是不完整的。:focus 被有意地忽略了,只对其偏移量进行了调整;焦点环在 Firefox 和 Safari 上看起来很棒

A screenshot of focus ring focused on a switch in Firefox and Safari.

已选中

<label for="switch-checked" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>

此状态表示 on 状态。在此状态下,输入“轨道”背景设置为活动颜色,滑块位置设置为“末尾”。

.gui-switch > input:checked {
  background: var(--track-color-active);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

已禁用

<label for="switch-disabled" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>

:disabled 按钮不仅在视觉上看起来不同,而且还应使元素变为不可变。交互不可变性是浏览器免费提供的,但由于使用了 appearance: none,视觉状态需要样式。

.gui-switch > input:disabled {
  cursor: not-allowed;
  --thumb-color: transparent;

  &::before {
    cursor: not-allowed;
    box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);

    @media (prefers-color-scheme: dark) { & {
      box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
    }}
  }
}

The dark styled switch in disabled, checked, and unchecked
states.

此状态很棘手,因为它需要具有禁用和选中状态的深色和浅色主题。我在样式上选择了最简单的样式用于这些状态,以减轻维护样式组合的负担。

不确定

一个经常被遗忘的状态是 :indeterminate,其中复选框既未选中也未取消选中。这是一个有趣的状态,它既吸引人又不引人注目。很好地提醒我们,布尔状态可能具有隐藏的中间状态。

将复选框设置为不确定状态很棘手,只有 JavaScript 才能设置它

<label for="switch-indeterminate" class="gui-switch">
  Indeterminate
  <input type="checkbox" role="switch" id="switch-indeterminate">
  <script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>

The indeterminate state which has the track thumb in the
middle, to indicate undecided.

由于在我看来,此状态不引人注目且具有吸引力,因此将开关滑块位置放在中间感觉很合适

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

悬停

悬停交互应为连接的 UI 提供视觉支持,并为交互式 UI 提供方向。当标签或输入悬停时,此开关会使用半透明环突出显示滑块。然后,此悬停动画为交互式滑块元素提供方向。

“高亮”效果是通过 box-shadow 完成的。在非禁用输入的悬停状态下,增加 --highlight-size 的大小。如果用户可以接受动态效果,我们会转换 box-shadow 并看到它增长,如果他们不能接受动态效果,则高亮会立即出现

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

.gui-switch > input:not(:disabled):hover::before {
  --highlight-size: .5rem;
}

JavaScript

对我而言,开关界面在尝试模拟物理界面时会让人感觉很奇怪,尤其是这种轨道内有圆圈的类型。iOS 在其开关方面做得很好,您可以左右拖动它们,并且可以选择这种操作非常令人满意。相反,如果尝试拖动手势但没有任何反应,则 UI 元素可能会感觉处于非活动状态。

可拖动滑块

滑块伪元素从 .gui-switch > input 作用域的 var(--thumb-position) 获取其位置,JavaScript 可以在输入上提供内联样式值以动态更新滑块位置,使其看起来跟随指针手势。释放指针后,移除内联样式,并通过使用自定义属性 --thumb-position 来确定拖动是更接近关闭还是开启。这是该解决方案的支柱;指针事件有条件地跟踪指针位置以修改 CSS 自定义属性。

由于组件在显示此脚本之前已经 100% 可用,因此维护现有行为(例如单击标签以切换输入)确实需要大量工作。我们的 JavaScript 不应以牺牲现有功能为代价来添加功能。

touch-action

拖动是一种手势,一种自定义手势,这使其成为 touch-action 优势的绝佳候选对象。对于此开关,水平手势应由我们的脚本处理,或者对于垂直开关变体,应捕获垂直手势。借助 touch-action,我们可以告诉浏览器在此元素上处理哪些手势,以便脚本可以在没有竞争的情况下处理手势。

以下 CSS 指示浏览器,当指针手势从该开关轨道内部开始时,处理垂直手势,对水平手势不做任何处理

.gui-switch > input {
  touch-action: pan-y;
}

所需的结果是水平手势不会同时平移或滚动页面。指针可以从输入内部垂直滚动并滚动页面,但水平滚动是自定义处理的。

像素值样式实用程序

在设置和拖动期间,需要从元素中获取各种计算的数值。以下 JavaScript 函数返回给定 CSS 属性的计算像素值。它在设置脚本中像这样使用 getStyle(checkbox, 'padding-left')

​​const getStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}

const getPseudoStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}

export {
  getStyle,
  getPseudoStyle,
}

请注意 window.getComputedStyle() 如何接受第二个参数,即目标伪元素。JavaScript 可以从元素(甚至从伪元素)中读取如此多的值,这真是太棒了。

正在拖动

这是拖动逻辑的核心时刻,函数事件处理程序中有一些需要注意的事项

const dragging = event => {
  if (!state.activethumb) return

  let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
  let directionality = getStyle(state.activethumb, '--isLTR')

  let track = (directionality === -1)
    ? (state.activethumb.clientWidth * -1) + thumbsize + padding
    : 0

  let pos = Math.round(event.offsetX - thumbsize / 2)

  if (pos < bounds.lower) pos = 0
  if (pos > bounds.upper) pos = bounds.upper

  state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}

脚本英雄是 state.activethumb,即此脚本正在定位的小圆圈以及指针。switches 对象是一个 Map(),其中键是 .gui-switch,值是缓存的边界和大小,这些边界和大小使脚本保持高效。从右到左的处理方式与 CSS 相同,即 --isLTR,并且能够使用它来反转逻辑并继续支持 RTL。event.offsetX 也很有价值,因为它包含一个增量值,可用于定位滑块。

state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)

CSS 的最后一行设置了滑块元素使用的自定义属性。此值分配原本会随着时间推移而转换,但之前的指针事件已暂时将 --thumb-transition-duration 设置为 0s,从而消除了原本会很缓慢的交互。

拖动结束

为了允许用户将滑块拖动到开关外部很远的位置并松开,需要注册全局窗口事件

window.addEventListener('pointerup', event => {
  if (!state.activethumb) return

  dragEnd(event)
})

我认为非常重要的是,用户可以自由地随意拖动,并且界面足够智能,可以考虑到这一点。使用此开关来处理它并不需要太多,但确实需要在开发过程中仔细考虑。

const dragEnd = event => {
  if (!state.activethumb) return

  state.activethumb.checked = determineChecked()

  if (state.activethumb.indeterminate)
    state.activethumb.indeterminate = false

  state.activethumb.style.removeProperty('--thumb-transition-duration')
  state.activethumb.style.removeProperty('--thumb-position')
  state.activethumb.removeEventListener('pointermove', dragging)
  state.activethumb = null

  padRelease()
}

与元素的交互已完成,是时候设置输入的 checked 属性并移除所有手势事件了。复选框通过 state.activethumb.checked = determineChecked() 进行更改。

determineChecked()

此函数由 dragEnd 调用,用于确定滑块当前在其轨道边界内的位置,如果滑块等于或超过轨道长度的一半,则返回 true

const determineChecked = () => {
  let {bounds} = switches.get(state.activethumb.parentElement)

  let curpos =
    Math.abs(
      parseInt(
        state.activethumb.style.getPropertyValue('--thumb-position')))

  if (!curpos) {
    curpos = state.activethumb.checked
      ? bounds.lower
      : bounds.upper
  }

  return curpos >= bounds.middle
}

额外想法

由于最初选择的 HTML 结构,拖动手势导致了一些代码债务,最值得注意的是将输入包装在标签中。标签作为父元素,会在输入之后接收点击交互。在 dragEnd 事件结束时,您可能已经注意到 padRelease() 是一个听起来很奇怪的函数。

const padRelease = () => {
  state.recentlyDragged = true

  setTimeout(_ => {
    state.recentlyDragged = false
  }, 300)
}

这是为了解决标签获得此稍后点击的问题,因为它会取消选中或选中用户执行的交互。

如果我要再次执行此操作,我可能会考虑在 UX 升级期间使用 JavaScript 调整 DOM,以创建一个可以自行处理标签点击并且不会与内置行为冲突的元素。

这种 JavaScript 是我最不喜欢编写的,我不想管理条件事件冒泡

const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}

结论

这个小小的开关组件最终成为了迄今为止所有 GUI 挑战中最耗费精力的一项!既然您已经了解了我是如何做到这一点的,您会如何做呢‽ 🙂

让我们使我们的方法多样化,并学习在 Web 上构建的所有方法。创建一个演示,在 Twitter 上给我发送链接,我会将其添加到下面的社区混音版部分!

社区混音版

资源

GitHub 上的源代码 中查找 .gui-switch