构建 switch 组件

简要介绍如何构建响应式且无障碍的开关组件。

在这篇博文中,我想分享关于构建 switch 组件的想法。试用演示版

演示

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

概览

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

此演示版使用 <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%);
  }
}

动作幅度减少

如需添加清晰的别名并减少重复,可以使用 PostCSS 插件将减少动作偏好的用户媒体查询放入自定义属性中,具体取决于媒体查询 5 中的草稿规范

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

Markup

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

一个自然的、未设置样式的标签和复选框。

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

<input type="checkbox"> 已预构建,附带 APIstate。浏览器管理 checked 属性和输入事件,例如 oninputonchanged

布局

Flexboxgrid自定义属性对于维护此组件的样式至关重要。它们可集中值,为其他不明确的计算或区域命名,并支持小型自定义属性 API,以便轻松自定义组件。

.gui-switch

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

Flexbox 开发者工具叠加在水平标签和开关上,显示其布局的空间分布。

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

扩展和修改 Flexbox 布局就像更改任何 Flexbox 布局一样。例如,如需将标签放置在开关上方或下方,或更改 flex-direction,请使用以下代码:

Flexbox 开发者工具叠加在垂直标签上并切换。

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

跟踪

通过移除复选框输入的常规 appearance: checkbox 并改为提供自己的大小,可将复选框输入设置为开关轨道:

网格开发者工具叠加在切换轨道上,显示名为“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;
}

该轨道还会创建一个逐个单元格的网格轨迹区域,以供拇指进行声明。

拇指琴声

样式 appearance: none 还会移除浏览器提供的视觉对勾标记。此组件对输入使用伪元素:checked 伪类来替换此视觉指示器。

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

显示伪元素滑块位于 CSS 网格内部的开发者工具。

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

风格

自定义属性支持通用的开关组件,它会根据配色方案、从右到左书写的语言以及动作偏好设置进行相应调整。

开关的浅色主题和深色主题及其状态的并排比较。

触摸互动样式

在移动设备上,浏览器会向标签和输入源添加点按突出显示和文本选择功能。这些因素对此次切换所需的样式和视觉互动反馈产生了负面影响。只需几行 CSS,我就可以移除这些效果,并添加我自己的 cursor: pointer 样式:

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

并不总是建议移除这些样式,因为它们可能是有价值的视觉互动反馈。如果您移除了相关内容,请务必提供自定义替代文本。

跟踪

该元素的样式主要与其形状和颜色有关,它可以通过级联从父级 .gui-switch 获取这些样式和颜色。

具有自定义轨道尺寸和颜色的 switch 变体。

.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%;
}

显示突出显示圆形滑块伪元素的开发者工具。

互动

使用自定义属性为显示悬停突出显示和拇指位置变化的互动做好准备。在转换动画或悬停突出显示样式之前,系统也会检查用户的偏好设置

.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,而 Thumb 伪元素将其用作 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 完成的,该类会向 input 元素添加带有 CSS 转换的旋转。

但是,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 上看起来非常美观:

Firefox 和 Safari 中聚焦于一个开关上的聚焦环的屏幕截图。

已选中

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

此状态表示 on 状态。在此状态下,输入“track”背景会设置为活动颜色,拇指位置设置为“end”。

.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%);
    }}
  }
}

深色样式的开关处于已停用、已选中和未选中状态。

这种状态比较复杂,因为它需要同时具有已停用和选中状态的深色主题和浅色主题。我从样式上为这些状态选择了最小的样式,以减轻样式组合的维护负担。

不确定

一种经常被忘记的状态是 :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>

不确定状态,滑道拇指位于中间,用于指示未定。

对我来说,这种状态并不显眼且富有吸引力,因此将开关滑块放在中间是合适的:

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

悬停

悬停互动应为已连接的界面提供视觉支持,也应为交互式界面提供方向。当标签或输入框将鼠标悬停在上方时,此开关会使用半透明环突出显示拇指。然后,此悬停动画提供了指向交互式拇指元素的方向。

“突出显示”效果是通过 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 的开关是对的,您可以左右拖动,能够选择这个选项会让人非常满意。相反,如果尝试执行拖动手势且没有任何反应,界面元素可能会感觉处于非活跃状态。

可拖动的滑块

拇指伪元素从作用域为 .gui-switch > inputvar(--thumb-position) 接收其位置,JavaScript 可以在输入中提供内嵌样式值,以动态更新拇指位置,使其看起来像遵循指针手势。松开指针后,请移除内嵌样式,并使用自定义属性 --thumb-position 确定拖动操作是更接近关闭还是开启。这是该解决方案的支柱;指针事件有条件地跟踪指针位置,以修改 CSS 自定义属性。

由于在显示此脚本之前该组件已完全正常运行,因此需要执行大量工作才能保持现有行为,例如点击标签切换输入源。我们的 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 可以从元素(甚至是伪元素)中读取这么多值。

dragging

这是拖动逻辑的核心时刻,函数事件处理脚本需要注意以下几点:

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 元素使用的自定义属性。此值分配会随着时间的推移而转换,但先前的指针事件已暂时将 --thumb-transition-duration 设置为 0s,从而消除了本应互动缓慢的情况。

dragEnd

为了允许用户将操作拖到开关之外并松开,需要注册一个全局窗口事件:

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()
}

已完成与元素的互动,现在该设置已检查输入属性并移除所有手势事件了。该复选框会变为 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)
}

这是考虑到后来点击的标签,因为它会取消选中或检查用户所执行的交互。

如果再次这样做,我可能考虑在用户体验升级期间使用 JavaScript 调整 DOM,以便创建一个能够自行处理标签点击且不与内置行为冲突的元素。

我最不喜欢编写此类 JavaScript,我不想管理条件式事件冒泡:

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

总结

这个小小的 switch 组件最终成为了迄今为止所有 GUI 挑战中最成功的作品!现在你已经知道我是怎么做的,希望你怎么办 ‽ 🙂?

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

社区混剪作品

资源

在 GitHub 上查找 .gui-switch 源代码