基本介紹如何建構回應式且無障礙的切換元件。
在這篇文章中,我想分享思考如何建構切換元件。立即試用。
如果你偏好使用影片,也可以觀看這篇 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%);
}
}
慢動作
如要新增清除別名並減少重複,可根據媒體查詢中的草稿規格,使用 PostCSS 外掛程式將動作偏好設定較低的使用者媒體查詢放入自訂屬性中:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
標記
我選擇將 <input type="checkbox" role="switch">
元素納入 <label>
,整合兩者關係,可避免核取方塊和標籤建立關聯,同時讓使用者能與標籤互動以切換輸入。
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
預先建構了 API 和state。瀏覽器會管理 checked
屬性和輸入事件,例如 oninput
和 onchanged
。
版面配置
Flexbox、grid 和自訂屬性對於維護此元件的樣式至關重要。這類函式會集中管理值、為名稱區分模糊計算或區域,以及啟用小型自訂屬性 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)
);
}
我以為這種分離的自動化調度管理方式非常實用。thumb 元素只需要一個樣式 (即 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 上看起來相當美觀:
已勾選
<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)
);
}
懸停
懸停互動應為已連結的 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 可以從元素讀取這麼多值,即使是虛擬元素也不例外。
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 挑戰的成就!現在既然你已經知道我怎麼做,你會怎麼做‽ 🙂?
讓我們帶您更多元的方法,並瞭解運用網路打造網站的所有方式。 請建立示範並透過 Twitter 推文連結,我就能將這項工具新增至下方的「社群重混」部分!
社群重混作品
- 搭配自訂元素的 @KonstantinRouda:示範和程式碼。
- @jhvanderschee 和 Codepen 按鈕。