CSSで、オンとオフを切り替えるスイッチを作る方法

​​CSS​HTML,Playwright,アクセシビリティ,フォーム

オンとオフを切り替えるスイッチは、<input type="checkbox"> に role="switch" を付けて見えなくし、すぐ後ろの span に土台とつまみを描いて、チェックされているとき(:checked)だけつまみを右へ動かすと、CSS だけで作れます。中身は普通のチェックボックスなので、クリックや Space キーでの切り替えは JavaScript を書かなくても動き、フォームで送る値も、普通のチェックボックスと変わりません。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS だけのページを Playwright で開き、マウスとキーボードで操作して、getComputedStyle() の値とアクセシビリティツリーを記録しました。Firefox と Safari、スクリーンリーダーでの実際の読み上げ、スマートフォンの実機でのタッチ操作は試していません。

「通知を受け取る」のスイッチを3回クリックする。クリックするたびに、白いつまみが左右に動き、土台の色が灰色と濃い水色で入れ替わる。下の「状態」の表示が「オン」「オフ」に切り替わる
スイッチの土台を3回クリックしたときの動き。切り替わる動きは、見やすいように実際の約4倍の時間をかけて再生している(上の回数表示、マウスの絵、押した位置のピンクの輪、下の状態の表示は GIF 用に足したもの)

HTML は、label の中にチェックボックスと土台を入れる

html
<label class="switch">
  <input class="switch__input" type="checkbox" role="switch" name="notify">
  <span class="switch__track" aria-hidden="true"></span>
  <span>通知を受け取る</span>
</label>

label の中に input を入れると、for と id を書かなくても、label とチェックボックスが結びつきます。MDN の label 要素の説明にあるとおり、結びついたラベルをクリックすると、チェックボックスそのものをクリックしたのと同じように切り替わります。検証でも、土台をクリックしたときも、「通知を受け取る」の文字をクリックしたときも、チェックが切り替わり、change イベントが起きました。

土台の span は見た目のためだけの要素なので、aria-hidden="true" を付けています。role="switch" については、後の節で説明します。

CSS は、チェックボックスを見えなくし、:checked でつまみを動かす

css
.switch {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  cursor: pointer;
}

/* チェックボックスは見えなくするが、フォーカスとキー操作は残す */
.switch__input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* 土台(トラック) */
.switch__track {
  position: relative;
  flex-shrink: 0;
  width: 48px;
  height: 28px;
  border-radius: 14px;
  background: #9aa5ab;
  transition: background-color 150ms ease-out;
}

/* 丸いつまみ */
.switch__track::before {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
  transition: transform 150ms ease-out;
}

/* オンのとき:土台の色を変え、つまみを右へ動かす(48 - 3 - 3 - 22 = 20px) */
.switch__input:checked + .switch__track {
  background: #067a98;
}

.switch__input:checked + .switch__track::before {
  transform: translateX(20px);
}

/* キーボードで選んだときだけ、土台の外側に枠を出す */
.switch__input:focus-visible + .switch__track {
  outline: 2px solid #067a98;
  outline-offset: 2px;
}

/* 押せないとき */
.switch__input:disabled + .switch__track {
  opacity: 0.5;
}

.switch:has(.switch__input:disabled) {
  cursor: not-allowed;
}

/* 動きを減らす設定のときは、すぐに切り替える */
@media (prefers-reduced-motion: reduce) {
  .switch__track,
  .switch__track::before {
    transition: none;
  }
}

チェックボックスは、大きさを 1px にして clip-path で切り抜き、画面からは見えなくしています。検証環境で大きさを読むと、幅も高さも 1px でした。見えないだけで、ページの中には残っているので、Tab キーで選ぶこともクリックで切り替えることもできます。

土台とつまみは、チェックボックスのすぐ後ろにある span と、その ::before で描きます。:checked は、チェックボックスがチェックされているときに当てはまる擬似クラスです。.switch__input:checked + .switch__track と書くと、チェックされたチェックボックスのすぐ後ろの土台にだけ、スタイルが当たります。

つまみが動く距離は、土台の幅 48px から、左右の余白 3px ずつと、つまみの幅 22px を引いた 20px です。クリックの前後で読んだ値は、次のとおりでした。

オフ オン
チェック(checked) false true
土台の色 rgb(154, 165, 171)(灰色) rgb(6, 122, 152)(濃い水色)
つまみの右への移動 なし 20px

切り替わる途中の動きも読みました。transition を一時停止し、時間を 25ms ずつ進めて、つまみの位置と土台の色を記録しています。距離は小数第1位に丸めました。

経過時間 つまみが右へ動いた距離 土台の色
0ms 0px rgb(154, 165, 171)
25ms 5.2px rgb(115, 154, 166)
50ms 9.8px rgb(82, 144, 162)
75ms 13.7px rgb(53, 136, 158)
100ms 16.9px rgb(29, 129, 155)
125ms 19.1px rgb(13, 124, 153)
150ms 20px rgb(6, 122, 152)

ease-out なので、最初の 25ms で 5.2px 動き、終わりに近づくほどゆっくりになります。土台の色も、同じ 150ms をかけて灰色から濃い水色へ変わりました。

display: none で隠すと、Tab キーで選べなくなる

チェックボックスを見えなくするだけなら display: none でもよさそうですが、比べてみると違いがありました。同じページに、チェックボックスを display: none にしたスイッチを置いて試した結果です。

1px にして切り抜く(記事の CSS) display: none
土台をクリックしたとき 切り替わる 切り替わる
Tab キーで選べるか 選べる 飛ばされる
アクセシビリティツリー switch として出る 出ない

display: none のスイッチも、土台をクリックすると切り替わりました。しかし、ページの先頭から Tab キーを押していくと、このスイッチは飛ばされ、focus() を呼んでもフォーカスは移りませんでした。アクセシビリティツリーにも switch が出ず、ラベルの文字だけが残りました。マウスを使わない人には、操作できないスイッチになってしまいます。

Tab と Space キーで操作でき、キーボードのときだけ枠が出る

ページを開き直して Tab キーを押すと、1回目でスイッチにフォーカスが当たりました。そのまま押したキーごとの結果です。

操作 チェック change イベント
Space を押す オンになる 起きる
Enter を押す オンのまま 起きない
もう一度 Space を押す オフになる 起きる

切り替わるのは Space キーで、Enter キーでは変わりませんでした。MDN の switch ロールの説明にも、スイッチの値を切り替えるキーとして、スペースキーが挙げられています。

フォーカスの枠は :focus-visible で出しています。チェックボックス自体は 1px で見えないので、枠はすぐ後ろの土台に付けます。:focus-visible は、ブラウザーがフォーカスを見せるべきだと判断したときだけ当てはまる擬似クラスです。

フォーカス :focus-visible 土台の枠
Tab キーで選んだとき ある 当てはまる 2px の実線
マウスでクリックしたとき ある 当てはまらない なし

マウスでクリックしたときも、フォーカスはチェックボックスに移りましたが、:focus-visible は当てはまらず、枠は出ませんでした。クリックのたびに枠が出ることはなく、キーボードで操作する人には、どこを選んでいるかが見えます。

上から4つのスイッチを並べたもの。1つ目はオフで、土台が灰色、つまみが左。2つ目はマウスでクリックしてオンにしたもので、土台が濃い水色、つまみが右、枠はない。3つ目は Tab キーで選んだオフのスイッチで、土台の外側に濃い水色の枠が出ている。4つ目は押せない(disabled)オンのスイッチで、土台とつまみが薄くなっている(文字の濃さは変わらない)
記事の CSS のスイッチを、実際に操作して撮ったもの。上から、オフ、マウスでクリックした後のオン、Tab キーで選んだとき、押せないとき(各行の見出しの文字は画像用に足したもの)

role="switch" で、スイッチとして伝わる

role="switch" を付けたチェックボックスと、付けないチェックボックスで、Playwright でアクセシビリティツリーを読みました。

text
- switch "通知を受け取る"
- text: 通知を受け取る
- switch "自動で保存する" [checked] [disabled]
- text: "自動で保存する display: none で隠したスイッチ"
- checkbox "role を付けないスイッチ"

role="switch" を付けた方は switch として、付けない方は見た目がスイッチでも checkbox として出ています。MDN の switch ロールの説明によると、switch は checkbox と機能は同じですが、「チェックされている/いない」ではなく「オン/オフ」を表すロールです。

オンかオフかは、チェックボックスの checked の状態がそのまま使われ、[checked] として出ました。aria-checked を書く必要はありません。W3C の ARIA in HTML でも、input type=checkbox には aria-checked を使ってはならず、switch のロールでは HTML の checked を代わりに使えるとされています。

なお、MDN のチェックボックスの説明には、チェックボックスをスイッチとして表示する switch 属性が載っています。ただし実験段階の属性で、対応していないブラウザーでは無視されるとあります。検証環境の Chromium 153 では、switch 属性を付けても大きさは普通のチェックボックスと同じ 13px 四方のままで、アクセシビリティツリーでも checkbox として出ました。このバージョンの Chromium でスイッチの見た目と switch のロールを持たせるには、この記事のように CSS と role="switch" で作る必要があります。

disabled と、フォームで送られる値

checked と disabled を付けたスイッチは、クリックしてもオンのままで、change イベントも起きませんでした。土台の opacity は 0.5 になり、label のカーソルは not-allowed になりました。Tab キーでも飛ばされます。アクセシビリティツリーでは [disabled] が付きました。

form の中に置いたスイッチの値を FormData で読むと、普通のチェックボックスと同じ値になりました。

FormData の中身
オフのとき 空(notify が入らない)
オンのとき notify=on

MDN のチェックボックスの説明にあるとおり、チェックされていないチェックボックスは、名前も値も送られません。value を書いていないときに送られる値は on です。検証でも、オフのときの FormData には notify 自体が入っていませんでした。

動きを減らす設定のときは、すぐに切り替える

prefers-reduced-motion は、ユーザーが端末で動きを減らす設定にしているかを調べるメディア特性です。Playwright でこの設定を reduce にしてクリックすると、つまみの transition-duration は 0s になり、動いているアニメーションは 0 個でした。つまみはクリックの直後から、右へ 20px 動いた位置にありました。動きはなくなっても、土台の色とつまみの位置で、オンとオフは見分けられます。

​Posted by 86