简要介绍了如何构建响应迅速且易于访问的开关组件。
在本文中,我想分享有关构建开关组件的思考。试用演示版。
如果您更喜欢视频,请观看此帖子的 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);
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">
预构建了 API 和状态。浏览器会管理 checked
属性和 输入事件,例如 oninput
和 onchanged
。
布局
Flexbox、网格和自定义属性对于维护此组件的样式至关重要。它们可集中管理值,为原本模糊不清的计算或区域命名,并启用一个小型自定义属性 API,以便轻松自定义组件。
.gui-switch
开关的顶级布局是 flexbox。类 .gui-switch
包含子项用于计算其布局的私有和公共自定义属性。
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
扩展和修改 Flexbox 布局就像更改任何 Flexbox 布局一样。
例如,如需将标签放在开关上方或下方,或更改 flex-direction
,请执行以下操作:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
跟踪
通过移除其正常的 appearance: checkbox
并改为提供自己的大小,将复选框输入设置为开关滑块的样式:
.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
,它会堆叠在滑道的顶部,而不是底部:
.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
访问这些样式。
.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
,滑块伪元素将其用作 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 上看起来非常棒:
已选中
<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%);
}}
}
}
此状态比较棘手,因为它需要同时具有停用和已选中状态的深色和浅色主题。我为这些状态选择了极简的样式,以减轻样式组合的维护负担。
未确定
一个经常被遗忘的状态是 :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 > input
作用域的 var(--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-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()
}
总结
这个小小的开关组件是迄今为止所有 GUI 挑战中最费心的工作!现在您已经知道我是如何解决的,您会怎么做? 🙂?
让我们多元化我们的方法,了解在 Web 上构建的所有方式。 制作一个演示版,在推特上向我发送链接,我会将其添加到下方的社区混剪部分!
社区混剪作品
- @KonstantinRouda 使用自定义元素:演示和代码。
- @jhvanderschee 并附上按钮:Codepen。