CSSで、オンとオフを切り替えるスイッチを作る方法
オンとオフを切り替えるスイッチは、<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、スクリーンリーダーでの実際の読み上げ、スマートフォンの実機でのタッチ操作は試していません。

HTML は、label の中にチェックボックスと土台を入れる
<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 でつまみを動かす
.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 は当てはまらず、枠は出ませんでした。クリックのたびに枠が出ることはなく、キーボードで操作する人には、どこを選んでいるかが見えます。

role="switch" で、スイッチとして伝わる
role="switch" を付けたチェックボックスと、付けないチェックボックスで、Playwright でアクセシビリティツリーを読みました。
- 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 動いた位置にありました。動きはなくなっても、土台の色とつまみの位置で、オンとオフは見分けられます。





