GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

css トグルスイッチ 9選 — 実物みたいなスイッチをコピペで

css トグルスイッチは、画面の中で電源を入れたり切ったりする小さな切り替えのことです。9 つすべてをライブラリなし、チェックボックス1つで作りました。下のグリッドで今動いており、クリック・Space・ドラッグはどれも本物です。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は人気順ではなく、スイッチが実物らしくなっていく順です。まず押し心地と光が生まれ(01)、ガラスの光沢が乗り(02)、個性が加わり(03)、つや消しで落ち着き(04)、2 コマから選ぶ形ができ(05)、光が流れ(06)、隠れていた帯が現れ(07)、チェックで確定し(08)、最後に指を追う慣性がつきます(09)。8 つは role="switch" と aria-checked で、2 コマから選ぶ 05 だけは aria-pressed で状態を知らせ、色だけでなく位置・文字・光でもオンが読めるようにしました。同じ button 系ではレバー・ダイヤル部品を扱う CSS スイッチボタン 9 選と、平面トグルの基本をまとめた CSS トグルスイッチ 9 選がこの記事の隣です。

01電源ボタンと LED リング

同心円のベベルを重ねた丸いボタンが押されると沈み、縁の電源記号リングに光がにじむ、機器コントロールパネルの主電源です。

電源ボタンと LED リング
新しいタブで開く

01 · 電源ボタンと LED リング — 計測 累積面積 5.355% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

box-shadowtranslateYaria-checked

オンとオフで時間が違う

// LED リング — オンの時はキャップが戻った 220ms 後から 300ms かけてにじみ、
// オフの時は遅延なく素早く消える(方向ごとに transition 規則を変える一方通行の技法)。
.pw__led {
  position: absolute; inset: 22px; z-index: 2; border-radius: 50%; pointer-events: none;
  box-shadow: inset 0 0 0 3px rgba($color, .14);
  transition: box-shadow $dur-quick $ease-out;
}
.pw.is-on .pw__led {
  box-shadow: inset 0 0 0 3px $color, 0 0 16px rgba($color, .6), 0 0 34px rgba($color, .28);
  transition: box-shadow $dur-base $ease-out $duration;
}

基本の規則がオフ方向(短く遅延なし)、.is-on の規則がオン方向(ボタンが戻ってから広がる)を上書きするので、状態ごとに delay を変えるだけで実物の非対称が生まれます。

押し込みは影も一緒に深くなる

.pw__cap:active {
  transform: translateY(4px) scale(.98);
  box-shadow: 0 2px 4px rgba(0, 0, 0, .6), inset 0 1px 0 rgba($subject-cream, .22), inset 0 -2px 4px rgba(0, 0, 0, .6);
}

キャップが 4px 沈むと同時に外側の影も縮みます。押し込みの深さは目よりも手が先に気づく情報なので、transform と box-shadow は同じ時間に束ねています。

02ガラス光沢の縦トグル 2 連

縦に立った溝の中をガラス光沢のノブが上下に滑る、機材パネルの 2 チャンネル分です — 2 つが逆の状態で並ぶので 0 と 1 を見比べられます。

ガラス光沢の縦トグル 2 連
新しいタブで開く

02 · ガラス光沢の縦トグル 2 連 — 計測 累積面積 10.045% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

linear-gradienttranslateY:checked

チェックボックス自身が状態を決める

.ch__input:checked ~ .ch__track .ch__knob {
  transform: translateY(var(--travel));
  box-shadow: 0 9px 14px rgba($stage-ink, .34), inset 0 -3px 6px rgba($stage-ink, .28), inset 0 1px 0 rgba($stage-paper, .9);
}

入力は <input type="checkbox" role="switch"> 1 つなので、トグル用の JS は 0 行で、:checked の兄弟セレクタがノブの位置・影・数字の強調を一度に切り替え、Space もチェックボックスが受け持ちます。

ガラスはスペキュラ 1 点で読める

.ch__knob {
  position: absolute; left: 9px; top: 9px; width: 46px; height: 46px; border-radius: 50%;
  background: linear-gradient(180deg, rgba($stage-paper, .95), rgba($color, .6) 45%, rgba($stage-paper, .35) 78%, rgba($subject-ink, .22));
  box-shadow: 0 3px 5px rgba($stage-ink, .4), inset 0 -3px 6px rgba($stage-ink, .28), inset 0 1px 0 rgba($stage-paper, .9);
  transform: translateY(0);
  transition: transform $duration $easing, box-shadow $duration $easing;
}
.ch__knob::before {
  content: ""; position: absolute; left: 16%; top: 12%; width: 44%; height: 32%;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba($stage-paper, .95), rgba($stage-paper, 0));
}

縦グラデーションに左上の楕円ハイライトを 1 つ乗せるだけでガラスに読めます。着地した状態の影は浮いた状態より遠くに広がり、その幅が溝の底に届いたという手がかりです。

03転がって目を開けるトグル

ノブが滑る代わりに一回転して反対側へ行き、着いたところで閉じていた両目が開く、子ども向けアプリの音トグルです。

転がって目を開けるトグル
新しいタブで開く

03 · 転がって目を開けるトグル — 計測 累積面積 9.99% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotatescaleYcubic-bezier

移動と回転を 2 層に分ける

// tr__knob は移動だけ、tr__roll は回転だけ — 目(tr__eyes)は回転の外に置いて
// 丸ごと転がっても起きて着くようにする。
.tr__knob {
  position: absolute; left: 9px; top: 8px; width: 69px; height: 69px;
  transform: translateX(0);
  transition: transform $duration $easing;
}
.tr__roll {
  position: absolute; inset: 0; border-radius: 50%;
  background: linear-gradient(160deg, $color, rgba($color, .88) 45%, rgba($subject-ink, .16) 92%);
  box-shadow: 0 4px 8px rgba($stage-ink, .45), inset 0 2px 2px rgba($stage-paper, .8), inset 0 -3px 5px rgba($stage-ink, .22);
  transform: rotate(0deg);
  transition: transform $duration $easing;
}
.tr.is-on .tr__knob { transform: translateX(var(--travel)); }
.tr.is-on .tr__roll { transform: rotate(var(--roll)); }

外側の層が translateX で 163px 進み、内側の層が同じ時間に 271 度回ります。角度は移動距離 ÷ 半径の近似値なので厳密でなくても、2 つの動きが同時に終われば車輪に読めます。

目は回転軸から外に置く

.tr__eye {
  width: 8px; height: 8px; border-radius: 50%; background: $subject-ink;
  transform: scaleY(0);
  transition: transform $dur-quick $ease-pop;
}
.tr.is-on .tr__eye { transform: scaleY(1); }

目は回転する層ではなくその上の層に置くので、ノブが転がっても起きたまま着きます。閉じは scaleY(0) の細い線なので、回転と重なっても形が崩れません。

04つや消しキーキャップトグル 3 連

正方形に近いキーキャップが 3 つ並び、押すと沈みながらキャップが青く満ちる、ツールバーのオンオフボタンです。

つや消しキーキャップトグル 3 連
新しいタブで開く

04 · つや消しキーキャップトグル 3 連 — 計測 累積面積 7.817% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

box-shadowbackground-colortransition

押し込みと色の充填は時間を分ける

.eq__key {
  display: flex; align-items: center; justify-content: center;
  width: 62px; height: 62px; margin: 0; padding: 0;
  border: 0; border-radius: $r-control; cursor: pointer;
  color: $subject-ink; // アイコン(currentColor)はキーから受け継ぐ — eqIcon キーフレームがこの color を動かす
  background-color: $cap-off;
  box-shadow: $cap-rest-shadow;
  transform: translateY(0);
  transition: transform $dur-instant $easing, box-shadow $dur-instant $easing, background-color $duration $ease-out, color $duration $ease-out;
}
.eq__key.is-on { background-color: $color; color: $subject-cream; }

押し込み(transform・box-shadow)は 140ms、色の充填は 220ms に分けました。ぱっと沈んで、色が後から追う順序が生まれます。

3 キーは同じキーフレームに負の遅延だけ違う値

.eq.is-demo .eq__key {
  animation-name: eqPress, eqFill, eqIcon;
  animation-duration: $dur-loop;
  animation-timing-function: $easing, steps(1, end), steps(1, end);
  animation-iteration-count: infinite;
}
.eq.is-demo .eq__key:nth-child(2) { animation-delay: calc(#{$dur-loop} / -3 * 2); }
.eq.is-demo .eq__key:nth-child(3) { animation-delay: calc(#{$dur-loop} / -3); }

キーフレームは 1 組のままで animation-delay を周期の 3 分の 1 ずつ負にずらし、3 キーが順に点きます。負の遅延には待ち時間がないので fill-mode も要りません。

052 コマを滑る選択ピル

長いピル型トラックの中を濃いピルが 2 つのラベルの間で滑り、乗ったほうの文字だけ色が反転する、日・週のような期間の二択です — コマは均等グリッドなので、ピルがラベルを外しません。

2 コマを滑る選択ピル
新しいタブで開く

05 · 2 コマを滑る選択ピル — 計測 累積面積 8.249% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

grid-template-columnstranslateXsteps(1, end)

コマは均等グリッド、移動は固定距離

.sg__track {
  --travel: 116px; // コマ幅 = トラック 240 − 左右パディング 8 を半分にした値。狭い画面では 96px
  display: grid; grid-template-columns: repeat(2, 1fr);
  position: relative;
  width: 240px; height: 48px; padding: $sp-1;
  border-radius: $r-pill;
  background: $subject-blue-deep;
}
.sg.is-week .sg__knob { transform: translateX(var(--travel)); }

インジケーターが固定 px で動くので、2 コマは repeat(2, 1fr) の均等グリッドで組み、flex: 1 1 0 のコマは実際の幅が固定値とずれてピルがラベルを外すため、狭い画面では --travel だけ縮めます。

金属面は上が明るく下が暗い 1 層

.sg__knob {
  position: absolute; left: 8px; top: 4px;
  width: 108px; height: 40px; border-radius: $r-pill;
  background: linear-gradient(180deg, rgba($subject-cream, .16), rgba($subject-cream, .02) 34%, rgba(0, 0, 0, .38)), $subject-ink;
  box-shadow: inset 0 1px 0 rgba($subject-cream, .3), 0 4px 10px rgba(0, 0, 0, .5);
  transform: translateX(0);
  transition: transform $duration $easing;
}
.sg__cell[aria-pressed="true"] { color: $subject-cream; }

ピルのノブはインクの地に上部の光沢 1 層と両端の縦グリップです。コマは <button aria-pressed> なので Tab と Enter でも切り替わり、ピルが着くとそのコマの文字だけ濃くなります。

06光が流れるガラストグル

ノブが渡る間にガラス表面の光のかたまりが斜めに流れ、星の点がノブの到着に合わせて明るくなる、暗い画面の中央に置く大きめのナイトモードスイッチです。

光が流れるガラストグル
新しいタブで開く

06 · 光が流れるガラストグル — 計測 累積面積 11.507% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

radial-gradienttranslateXbox-shadow

光は空のトラックに重なる

.onx__light {
  position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  background: radial-gradient(120% 150% at 32% 50%, rgba($color, .96), rgba($subject-blue-deep, .92) 62%, rgba($subject-blue-deep, .8));
  opacity: 0;
  transition: opacity $dur-enter $ease-out;
}
.onx.is-on .onx__light { opacity: 1; }

オフではトラックが空で、オンになると明るい中心を持つ放射グラデーションが 600ms かけて満ちます。トラック・光・ノブは別々の層なので、色がスイッチ自体に滲みません。

反射光は反対側へゆっくり流れる

.onx__sweep {
  position: absolute; left: 44%; top: 12%; width: 52%; height: 76%;
  border-radius: 50%; pointer-events: none;
  background: linear-gradient(52deg, rgba($stage-paper, 0) 34%, rgba($stage-paper, .46) 56%, rgba($stage-paper, 0) 78%);
  transform: translateX(0) rotate(0deg);
  transition: transform $dur-enter $ease-out;
}
.onx.is-on .onx__sweep { transform: translateX(-76%) rotate(-9deg); }

斜めの帯の反射光がオンの瞬間に左へ流れて 9 度傾き、光源が動いたように読めます。星の点 5 つは到着時に点灯し、黄色いステージの状態文字はインクで対比を保っています。

07スライドさせるミュートスイッチ

ハンドルを端まで押すと溝の奥に隠れていたオレンジの帯が現れる、通知オフのような戻しやすい遮断スイッチです。

スライドさせるミュートスイッチ
新しいタブで開く

07 · スライドさせるミュートスイッチ — 計測 累積面積 4.199% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

insettranslateXsteps(1, end)

隠れていた帯が状態を語る

.sm__rail {
  position: relative; box-sizing: border-box;
  width: 124px; height: 48px; border-radius: $r-pill;
  background: linear-gradient(180deg, rgba($subject-ink, .95), rgba($subject-ink, .78));
  box-shadow: inset 0 3px 6px rgba($stage-ink, .9), inset 0 -1px 0 rgba($subject-cream, .22);
}
.sm__band {
  position: absolute; left: 4px; top: 4px; width: 56px; height: 40px;
  border-radius: $r-pill;
  background: linear-gradient(180deg, $color, rgba($stage-ink, .55));
  box-shadow: inset 0 2px 3px rgba($stage-ink, .5);
}

帯は最初から溝の内側に敷かれており、ハンドルが覆っているだけです。ハンドルが端へ行くとその場所が現れ、ミュートを色ではなく「現れたこと」で知らせます。

端のコマで一度弾んで収まる

.sm__handle {
  --travel: 56px; // ハンドルが反対側の端まで行く距離。狭い画面では下のメディアクエリが縮める
  transition: transform $duration $easing;
}
.sm.is-muted .sm__handle { transform: translateX(var(--travel)); }

$ease-pop のオーバーシュートがハンドルを端より 4px 先へ運んでから戻します。ハードウェアのスライドは途中で止まらないので、移動は 1 回の遷移で終え、--travel 変数だけで狭い画面でも同じ性格を保ちます。

08チェックに固まる確認トグル

ノブが反対側に着いた後でのみチェック線が一筆で描かれる、規約同意のように確定が大事な場所のトグルです。

チェックに固まる確認トグル
新しいタブで開く

08 · チェックに固まる確認トグル — 計測 累積面積 3.276% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

stroke-dashoffsetanimation-delaypolyline

チェックは SVG の一筆

<button class="cm__switch" id="cmSwitch" type="button" role="switch" aria-checked="false" aria-label="規約の同意">
  <span class="cm__knob" aria-hidden="true">
    <svg class="cm__check" viewBox="0 0 24 24" aria-hidden="true">
      <polyline points="5,12.5 10,17.5 19,7.5"></polyline>
    </svg>
  </span>
</button>

チェックを 2 本の棒で作ると始点がずれて 1 本の斜め線に読まれがちですが、SVG polyline 1 本の stroke-dashoffset を 26 から 0 へ減らすと折れ点が正確な一筆になります。

描き始めは移動が終わってから

.cm__knob {
  position: absolute; left: 4px; top: 4px; display: grid; place-items: center;
  width: 40px; height: 40px; border-radius: 50%;
  background: $stage-paper;
  box-shadow: 0 2px 4px rgba($subject-ink, .34);
  transform: translateX(0);
  transition: transform $duration $easing;
}
.cm.is-agreed .cm__knob { transform: translateX(var(--travel)); }
.cm.is-agreed .cm__check polyline { stroke-dashoffset: 0; transition-delay: $duration; }

「到着」と「確認」は別の合図なので重ねません。チェックの transition に transition-delay: $duration を置き、ノブが完全に着いた後に線が描かれます。切る時は遅延なくチェックが先に消えます。

09ドラッグして放す慣性トグル

ノブを指でドラッグすると指先に付いてきて、半分を越えて放すと残りを慣性で走り切る、誤操作で入ると困るロック解除スイッチです。

ドラッグして放す慣性トグル
新しいタブで開く

09 · ドラッグして放す慣性トグル — 計測 累積面積 5.107% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointerdownsetPointerCapturetranslateX

ドラッグ中だけ遷移を切る

.dt.is-dragging .dt__knob { transition: none; cursor: grabbing; }
.dt.is-dragging .dt__fill { transition: none; }

ドラッグ中は指先とノブがずれてはいけないので、.is-dragging が transition を切ります。手を離した瞬間にクラスが外れて遷移が戻り、その時に $ease-pop が残りの距離を走ります。

半分を越えたかどうかで着地が決まる

function settle(open) {
  dtKnob.style.transform = '';
  dtKnob.setAttribute('aria-checked', open ? 'true' : 'false');
  dtRoot.classList.toggle('is-open', open);
  offset = open ? travel() : 0;
}
function onUp() {
  if (!dragging) return;
  dragging = false;
  dtRoot.classList.remove('is-dragging');
  // 半分を越えていれば慣性で残りを走り、届かななければ元の位置へ戻る
  settle(offset > travel() / 2);
}

放した位置が半分を越えたかだけを見ます。ポインターは setPointerCapture でノブに縛り、トラックの外に出てもドラッグが切れないようにし、クリック・Space も同じ settle() を呼ぶので、キーボードだけでロックが解けます。

css チェック ボックス スイッチはどこまでできるか?

ここまでできます。状態はチェックボックスか aria-checked 1 つに置き、質感はグラデーションと box-shadow の数枚で、性格はイージングと時間の配分で作ると、電源ボタンから慣性トグルまで全部 css スイッチとしてライブラリなしで成り立ちます。ただし 3 つだけ別の規則があって、03 の回転・08 の一筆描き・09 のドラッグは状態ではなく動きそのものなので、プレビューのループと実際の入力経路を必ず分けて検証します。

どこで壊れるか — グリッドでだけ動くスイッチ

ドラッグ直後の click が状態を反転させた

09 の初版を Playwright で 30px ドラッグして放すと、aria-checked が true に反転していました。ドラッグが終わった場所で同じ要素に click イベントがもう 1 回飛び、トグルの処理が新しいクリックとして読んだのです。6px 以上動いた事実を覚えて続く click を飲み込む 2 行で直しました。計測はプレビューのループしか見ないので、入力経路のあるスイッチは必ず手で動かします。

子の color 宣言が親のアニメーションを殺した

04 はアイコンの色を .eq__icon { color: $subject-ink } で直接指定していたのに、3 つ目のキーのアイコンがプレビューのループ中ずっとオンの色のまま固まっていました。アニメーションは親キーの color を動かしますが、子の直接宣言が継承に勝ったのです。色をキーに移して受け継がせると 3 キーとも元の動きに戻りました。計算値をフレームごとに吐くまで、ソースのどこにも間違いは見えませんでした。

半分だけ色がついたキャップが代表画像になった

ポスターをレンダー済みフレームとピクセル単位で比べると、灰色と青の中間で色が半分だけさめたフレームが選ばれており、その上の暗いアイコンが薄く浮いて見えました。イージングの充填は中間色のフレームを数枚作るため、キャップとアイコンの色を同じキーフレームで steps(1, end) のカットとして一緒に反転させました。実際のクリックは静的規則の 220ms フェードのままなので、手触りは変わりません。9 ファイルの圧縮パスワードは 3hntnd6x で、9 つすべてクリック・Space・ドラッグを数値に残してあります。

3 つのバリエーションとアクセシビリティ

名前 変える値 感じ
duration $dur-base 300ms → $dur-instant 140ms ノブが滑らず機械部品のようにぴたりと収まる
easing $ease-spring → $ease-pop ノブが反対側の端で一度弾んでから定まる
color $subject-blue-deep → $stage-orange オンの色が青からオレンジになり警告スイッチに読める

8 つには role="switch" を付け(02 はチェックボックスが aria-checked を自然に公開)、2 コマ選択の 05 は aria-pressed を使います。状態は色だけでなくノブの位置・状態文字・帯の露出でも伝えるので、色の見え方に癖があってもオンが読めます。09 はポインターとキーボードが同じ settle() で終わり、prefers-reduced-motion: reduce ではプレビューのループが止まり、オフ位置の読みやすい静止画が 1 枚残ります。

さらに読む:CSS スイッチボタン 9 選はレバー・ロッカー・ダイヤル部品を、ボタンアニメーションガイドは時間とイージングの基準を扱います。仕様は MDN の switch roleと :checkedを見てください。

FAQ

トグルスイッチ html css を組むとき button と checkbox のどちらを使うべきですか

状態を自分で保持する必要があるなら、チェックボックスに role="switch" を乗せます(02 番)。Space とフォーム送信がそのまま付いてきます。アプリが状態を管理するなら、button の aria-checked を自分で保ちます(01・07・08・09 番)。

アイコンのスイッチは色だけでオンを示してもいいですか

04 番のように色が主な手がかりでも、押し込みの深さとアイコンの色反転が一緒にあります。青と灰色の見分けがつきにくい目もあるので、可能なら位置や文字など 2 つ目の手がかりを足す方が安全です。

ドラッグのトグルはタッチ画面でも動きますか

動きます。09 番は setPointerCapture でマウス・タッチ・ペンを 1 つの経路で受け、touch-action: none でドラッグ中のスクロールを止めています。指はちょうど半分の位置で止まりにくいので、半分の規則が意図通りに着地するか手で確かめてください。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。