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

モーションを抑制

明確なエイリアスを追加して繰り返しを減らすため、このメディアクエリ 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"> には、API状態がプリビルドされています。ブラウザは、checked プロパティと、oninputonchanged などの入力イベントを管理します。

レイアウト

Flexboxgridカスタム プロパティは、このコンポーネントのスタイルを維持するうえで重要です。値が中央に集約され、曖昧な計算や領域に名前が付けられ、コンポーネントを簡単にカスタマイズできる小さなカスタム プロパティ 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 を削除し、独自のサイズを指定することで、スイッチ トラックとしてスタイル設定されます。

スイッチ トラックにオーバーレイされたグリッド 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;
}

また、トラックは、サムネイルが要求する 1x1 の単一セル グリッド トラック領域も作成します。

サムネイル

スタイル 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 からカスケードを介してアクセスします。

カスタムのトラック サイズと色を持つスイッチ バリエーション。

.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 の位置)のみに関与します。入力は、すべての複雑さと計算を管理できます。

業種

サポートは、input 要素に CSS 変換で回転を追加する修飾子クラス -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 は、カスタム プロパティ値を使用してパーセンテージを反転させ、論理変換用の独自のカスタム ロジックを単一の場所で管理するという素晴らしいアイデアを思いつきました。このスイッチでも同じ手法を使用しましたが、うまくいったと思います。

.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 オブジェクトは 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)
}

これは、ユーザーが行った操作のチェックを外したり、チェックを入れたりするラベルが後でクリックされることを考慮するためです。

この処理を再度行う場合は、UX アップグレード中に JavaScript で DOM を調整して、ラベルのクリックを自身で処理し、組み込みの動作と競合しない要素を作成することを検討する可能性があります

このような JavaScript は書くのが最も苦手です。条件付きのイベント バブリングを管理したくありません。

const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}

まとめ

この小さなスイッチ コンポーネントが、これまでのすべての GUI チャレンジの中で最も手間がかかりました。私がどのように行ったかをご理解いただけたかと思います。では、あなたならどのようにしますか?🙂

アプローチを多様化し、ウェブで構築するさまざまな方法を学びましょう。デモを作成して、ツイートでリンクを送信してください。下のコミュニティ リミックス セクションに追加します。

コミュニティ リミックス

リソース

.gui-switchソースコードは GitHub で確認できます。