switch コンポーネントの作成

応答性とアクセシビリティに優れたスイッチ コンポーネントを作成する方法の基本的な概要です。

この投稿では、スイッチ コンポーネントを構築する方法について考えを共有します。デモをお試しください

デモ

動画をご覧になる場合は、この投稿の YouTube バージョンをご覧ください。

概要

スイッチはチェックボックスと同様に機能しますが、オン / オフ状態をブール値で明示的に表します。

このデモでは、機能の大部分で <input type="checkbox" role="switch"> を使用しています。そのため、CSS や JavaScript が完全に機能し、アクセス可能である必要はありません。CSS を読み込むと、右から左に記述する言語、垂直方向、アニメーションなどがサポートされます。JavaScript を読み込むと、切り替え操作がドラッグ可能で目に見えるようになります。

カスタム プロパティ

次の変数は、スイッチのさまざまな部分とそのオプションを表します。最上位のクラスである .gui-switch には、コンポーネントの子全体で使用されるカスタム プロパティと、一元化されたカスタマイズのエントリ ポイントが含まれています。

トラッキング

長さ(--track-size)、パディング、2 色:

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

マークアップ

そこで、<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"> には APIstateがあらかじめビルドされています。ブラウザは checked プロパティと、oninputonchanged などの入力イベントを管理します。

レイアウト

このコンポーネントのスタイルを維持するには、Flexboxグリッドカスタム プロパティが重要です。値を一元化し、あいまいな計算や領域に名前を付け、小規模なカスタム プロパティ API を有効にして、コンポーネントを簡単にカスタマイズできます。

.gui-switch

スイッチの最上位レイアウトは Flexbox です。.gui-switch クラスには、子がレイアウトを計算するために使用するプライベートおよびパブリックのカスタム プロパティが含まれています。

水平方向のラベルとスイッチの上に重ねて、スペースのレイアウト分布を示す Flexbox DevTools。

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

Flexbox レイアウトの拡張と変更は、Flexbox レイアウトの変更に似ています。たとえば、スイッチの上または下にラベルを配置したり、flex-direction を変更したりするには、次のようにします。

垂直のラベルとスイッチが重ねられた Flexbox DevTools。

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

トラッキング

チェックボックス入力は、通常の appearance: checkbox を削除し、代わりに独自のサイズを指定することで、スイッチ トラックとしてスタイル設定されます。

スイッチ トラックにオーバーレイし、「track」という名前の名前付きグリッド トラック領域を表示している Grid DevTools。

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

トラックは、つまみが要求する 1 行 1 のセルグリッド トラック領域も作成します。

また、スタイル appearance: none は、ブラウザから提供された視覚的なチェックマークを削除します。このコンポーネントは、入力に対して疑似要素:checked 疑似クラスを使用して、このビジュアル インジケーターを置き換えます。

つまみは、input[type="checkbox"] にアタッチされた疑似要素の子であり、グリッド領域 track を要求することでトラックの下ではなく上に積み重ねられます。

CSS グリッド内に配置された疑似要素のつまみを表示している DevTools。

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

切り替えトラックのさまざまなカスタマイズ オプションが 4 つのカスタム プロパティから用意されています。appearance: none ではすべてのブラウザでチェックボックスの枠線が削除されるわけではないため、border: none を追加しました。

つまみ要素はすでに右側の track にありますが、円のスタイルが必要です。

.gui-switch > input::before {
  background: var(--thumb-color);
  border-radius: 50%;
}

円のつまみ疑似要素がハイライトされ、DevTools でハイライト表示されている。

インタラクション

カスタム プロパティを使用して、カーソルを合わせたときのハイライト表示やつまみの位置の変化を表示する操作を準備する。モーションやカーソルを合わせたときのハイライト表示のスタイルを移行する前に、ユーザー設定も確認されます。

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

この分離されたオーケストレーションはうまく機能していると思いました。つまみ要素は、1 つのスタイル(translateX の位置)にのみ関係します。入力ですべての複雑さと計算を管理できます

業種

そのために、CSS 変換による回転を input 要素に追加する修飾子クラス -vertical が使用されていました。

ただし、要素を 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 と私は、1 つの変数を反転させることで、右から左に記述する言語を処理する CSS 変換を使用して、サイドメニューのスライドアウトのプロトタイプを作成しました。これは、CSS に論理プロパティ変換はなく、存在しない可能性があるためです。Elad は、カスタム プロパティ値を使用してパーセンテージを反転させ、論理変換用の独自のカスタム ロジックを 1 か所で管理できるようにすることを考えていました。このスイッチでも同じテクニックを使いましたが、うまくいったと思います。

.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 状態を表します。この状態では、入力「トラック」の背景はアクティブな色に設定され、つまみの位置は「終了」に設定されています。

.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() が 2 つ目の引数であるターゲット疑似要素を受け入れることに注目してください。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 オブジェクトは、キーは .gui-switchMap() です。値は、スクリプトの効率を保つためにキャッシュに保存された境界とサイズです。右から左に記述するプロパティは、CSS の --isLTR と同じカスタム プロパティを使用して処理され、ロジックを反転して RTL のサポートを継続するために使用できます。また、つまみの位置を決めるのに役立つデルタ値が含まれているため、event.offsetX も重要です。

state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)

CSS の最後の行では、つまみ要素で使用されるカスタム プロパティを設定します。そうしないと、この値の割り当ては時間の経過とともに変化しますが、以前のポインタ イベントによって一時的に --thumb-transition-duration0s に設定され、これまで動作が遅くなっていた操作が削除されます。

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 の課題の中で最も労力を費やすことになりました。私のやり方がわかったところで、どうしたらいいですか? 🙂?

多様なアプローチと、ウェブでの構築方法を学んでいきましょう。 デモを作成してツイートのリンクをお願いします。下のコミュニティ リミックス セクションに追加します。

コミュニティのリミックス

リソース

GitHub でソースコード .gui-switch を見つけます。