建立切換元件

概略說明如何建構可回應且無障礙的切換按鈕元件。

在本篇文章中,我想分享建構切換元件的方法。試用示範模式

示範

如果你偏好觀看影片,請參閱這篇文章的 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 外掛程式中的以下媒體查詢的草稿規格,透過 PostCSS 外掛程式將經過較少的動作偏好設定使用者媒體查詢放入自訂屬性中:

@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 屬性和輸入事件,例如 oninputonchanged

版面配置

Flexbox格線自訂屬性對於維持此元件的樣式至關重要。這些屬性可集中值、為不清晰的計算或區域命名,並啟用小型自訂屬性 API,方便自訂元件。

.gui-switch

切換鈕的頂層版面配置是彈性容器。類別 .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,並提供其自身大小,藉此將核取方塊輸入內容設為切換軌道樣式:

格線 DevTools 疊加切換軌道,顯示命名格線軌道區域,名稱為「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 存取。

含有自訂軌道大小和顏色的切換選項變化版本。

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

切換軌道的多種自訂選項來自四個自訂屬性。由於 appearance: none 不會移除所有瀏覽器核取方塊的邊框,因此已新增 border: 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,將 --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)
  );
}

懸停

懸停互動應為連結 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 自訂屬性。

由於元件在這個指令碼顯示前已完全運作,因此維持現有行為 (例如按一下標籤來切換輸入內容) 需要花費相當多的工作。我們的 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 相同的自訂屬性處理從右到左的方向,並可用於反轉邏輯,繼續支援從右到左的方向。event.offsetX 也相當實用,因為它包含 delta 值,可用於定位拇指。

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

結論

這個小小的切換元件,是目前為止所有 GUI 挑戰中,最費時的項目!既然你知道我如何做到,你會怎麼做呢? 🙂

讓我們來體驗更多元的方法,並瞭解在網路上建立內容的所有方式。 請製作示範作品,並在推特上傳連結,我會將其加入下方的社群重混曲目錄!

社群重混作品

資源

GitHub 上找出 .gui-switch 的原始碼。