基礎概念總覽:如何建構回應式無障礙切換元件。
在這篇文章中,我想分享如何建構切換元件的思考過程。 立即試用。
如果比較喜歡看影片,可以觀看這篇貼文的 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 外掛程式放入自訂屬性,依據 Media Queries 5 中的這項草案規格:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
標記
我選擇使用 <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
屬性和 input 事件,例如 oninput
和 onchanged
。
版面配置
Flexbox、格線和自訂屬性對於維護這個元件的樣式至關重要。這些符記可集中管理值、為原本模稜兩可的計算或區域命名,並啟用小型自訂屬性 API,方便自訂元件。
.gui-switch
切換按鈕的頂層版面配置是彈性方塊。類別 .gui-switch
包含子項用來計算版面配置的私有和公開自訂屬性。
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
擴充及修改彈性方塊版面配置,就像變更任何彈性方塊版面配置一樣。
舉例來說,如要將標籤放在切換鈕上方或下方,或是變更 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
完成,該類別會將 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
狀態。在此狀態下,輸入的「軌跡」背景會設為有效顏色,而滑桿位置則會設為「結尾」。
.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 元素可能會感覺處於非使用中狀態。
可拖曳的拇指
JavaScript 可在輸入內容中提供內嵌樣式值,動態更新滑桿位置,讓滑桿看起來像是跟隨指標手勢。.gui-switch > input
var(--thumb-position)
放開指標後,請移除內嵌樣式,並使用自訂屬性 --thumb-position
判斷拖曳動作是靠近「關閉」還是「開啟」。這是解決方案的骨幹;指標事件會視情況追蹤指標位置,以修改 CSS 自訂屬性。
由於元件在顯示這個指令碼前已完全正常運作,因此要維持現有行為 (例如點選標籤來切換輸入) 需要相當多的工作。我們的 JavaScript 不應以犧牲現有功能為代價新增功能。
touch-action
拖曳是自訂手勢,因此非常適合使用 touch-action
效益。如果是這個切換鈕,水平手勢應由指令碼處理,或是為垂直切換鈕變體擷取垂直手勢。有了 touch-action
,我們可以告知瀏覽器要在這個元素上處理哪些手勢,因此指令碼可以處理手勢,不會發生競爭。
以下 CSS 會指示瀏覽器,當指標手勢從這個切換軌內開始時,處理垂直手勢,對水平手勢則不執行任何動作:
.gui-switch > input {
touch-action: pan-y;
}
期望結果是水平手勢,不會同時平移或捲動網頁。指標可以從輸入內容內垂直捲動並捲動頁面,但水平指標是自訂處理。
Pixel 值樣式公用程式
在設定和拖曳期間,需要從元素中擷取各種計算出的數值。下列 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
的相同自訂屬性處理從右到左的語言,並可使用該屬性反轉邏輯,繼續支援從右到左的語言。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)
}
這是為了因應標籤稍後取得的點擊,因為這樣會取消勾選或勾選使用者執行的互動。
如果我再次執行這項操作,可能會考慮在 UX 升級期間使用 JavaScript 調整 DOM,建立可自行處理標籤點擊的元素,且不會與內建行為衝突。
這類 JavaScript 是我最不喜歡撰寫的內容,我不想管理條件式事件冒泡:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
結論
這個微小的切換元件,最後竟成為目前所有 GUI 挑戰中最耗費心力的項目!現在您已瞭解我的做法,您會怎麼做呢?🙂
讓我們多元化方法,學習在網路上建構內容的所有方式。 建立試聽版,然後在推特上傳送連結給我,我會將連結加到下方的社群混音區!
社群重混作品
- @KonstantinRouda 提供的自訂元素:demo 和程式碼。
- @jhvanderschee,並附上按鈕:Codepen。
資源
在 .gui-switch
GitHub 上查看原始碼。