ダークモードトグル
太陽↔月にモーフするトグルで昼夜を切替。ミニカードの背景・文字色・アイコンがアニメ遷移する今風のテーマスイッチ。
プレビュー
操作クリック/タップで動きます。
仕様書
- 向く場面
- ヘッダーなど常に見える位置のテーマ切替
- ライト/ダークを 1 タップで往復させたい場面
- 切替の結果を同じ画面で見せたいデモ
- 避ける場面
- 設定一覧の 1 項目として大きく置く
- OS 設定や保存を伴う本番のテーマ切替に未加工で使う
- 色だけで状態を示す
- 構成要素
- スイッチのレール
- つまみ(太陽/月)
- 文字ラベル
- 見本カード
- 持っている状態
- 通常
- キーボード
Tab でスイッチへ、Space/Enter で切替。:focus-visible でレールに 2px の輪郭が出る
- 支援技術
APG Switch: role=switch+aria-checked+aria-label「ダークモード切替」。初期値は OS の prefers-color-scheme。ライト/ダークの文字ラベルで色以外にも示す。アイコンは aria-hidden
- 動き
つまみ 400ms オーバーシュート、太陽⇄月 300〜400ms クロスフェード(回転+縮小)、カード配色 400ms。reduced-motion で停止
デザインの要点
- 初期値は OS の prefers-color-scheme から取る。ダーク設定の人に毎回ライトで開かせると、切替は「直す作業」になる
- 太陽と月は同じ位置に重ねて opacity と rotate(30deg)+scale(0.7) でクロスフェードする。差し替えより連続して見える
- つまみの移動 400ms とカードの配色 400ms を同じ長さにする。片方が先に終わると二段階に見えて安っぽい
- 文字ラベル「ライト/ダーク」を残す。月のアイコンが出ていても、今どちらかは文字のほうが速く分かる
使いどころ
デモ用のミニカード1枚だけを対象にライト/ダークを切り替える見本で、ページ全体のテーマ切替の完成品ではない点に注意が必要。実際のアプリで使うなら、この見た目(太陽↔月のノブが動くトグル)自体は「頻繁に使う設定」「常に見える位置に置きたい設定」に向いており、ヘッダーやフッターの固定位置に置くテーマ切替ボタンとして適している。逆に、設定ページの中の一項目として他の設定と並べる場合は、このカード付きの大きな見た目は場所を取りすぎるため、track+knobの部分だけを流用してシンプルなrole=switchのトグルにする方が収まりがよい。またこのサンプルは初期値をOSのprefers-color-schemeから取るだけでlocalStorageへの保存は行っていないため、実際に使うには選択の永続化とページ全体への適用ロジックを別途足す必要がある。
仕組み
toggle要素はrole="switch"とaria-checkedで状態を持ち、クリックのたびにaria-checkedの真偽を反転させてCSSの属性セレクタ([aria-checked="true"])でtrack・knob・sun/moonアイコンの見た目を切り替える、いわゆるARIA switchパターンで実装されている。knobの移動はtransform: translateX(32px)、太陽/月のアイコン切り替えはopacityとtransform(rotate+scale)のクロスフェードで、いずれもtransformとopacityだけで完結するためコンポジタ処理で軽い。カード側(tm-card)はJSがtm-darkクラスを付け外しするだけの独立したCSS連動で、トグルの状態そのものはtoggle要素のaria-checked属性が単一の情報源になっている。ラベル文言(ライト/ダーク)もJSでtextContentを書き換えているため、視覚のテキストとaria-checkedの状態が常に一致するよう1つのクリックハンドラの中で同時に更新されている。
実装の注意
最大の注意点は、このコンポーネントが実際のテーマを何一つ変更していないこと。tm-darkクラスが付け外しされるのはtm-card要素だけで、documentのdata-theme属性やCSS変数、localStorageへの保存は一切行われていない。実際のアプリに組み込む際は、クリックハンドラの中でdocument.documentElement.setAttribute("data-theme", ...)のような実際のテーマ適用と、次回訪問時に復元するための永続化を追加し忘れると、「切り替わったように見えるが実は何も変わっていない」バグになりやすい。次に、初期状態はapply(matchMedia("(prefers-color-scheme: dark)").matches)でOS設定だけを反映しており、ユーザーが前回選んだ値は見ていない点も見落としやすい。ページ読み込み時にlocalStorage等の保存値を優先し、無ければOS設定に倒す順序で初期値を決める処理を足す必要がある。
アクセシビリティ
role="switch"とaria-checked、固定のaria-label="ダークモード切替"を最初から備えており、ON/OFFの二値をトグルボタンとして扱う型はARIAのSwitchパターンに沿っている(aria-pressedを使うトグルボタン方式との違いは、switchの方が「設定のON/OFF」という意味を明確に持つ点で、この用途にはswitchの選択が妥当)。ラベル文言(ライト/ダーク)がテキストとしても存在するため、色だけに頼らず状態が伝わる点もよい。prefers-reduced-motion: reduceではknobのtranslateXやアイコンのrotate+scale、カードの配色といったtransitionをまとめて止め、切替は瞬時に終状態へ移る(状態の違いは文字ラベルと位置で伝わるので動きが無くても情報は失われない)。初期値はOS側のprefers-color-scheme(ユーザーがシステムでダークモードを選択している場合の既定値)を初期化時に参照して決めるので、視覚的な設定と実際の見た目の一貫性が初回表示から保たれる。フォーカスは:focus-visibleでトグル全体に2pxの輪郭を描く。
調整して使う
調整しやすいのは3つ。ノブの移動距離とtrackの幅(既定64×32px)で、より大きなタップ領域が必要なモバイル向けには72×36px程度に拡大しても比例して調整すれば破綻しない。次にアイコンで、太陽/月の組み合わせ以外にも晴れ/曇りのような別のメタファーに差し替え可能だが、その場合もopacity+transformのクロスフェードの構造はそのまま流用できる。最後に、このサンプルはtm-card1枚だけに適用しているが、実際にページ全体へ適用するにはCSS変数(--bg, --text等)をdata-theme属性に応じて切り替える形に設計を広げ、tm-cardへの直接クラス付与のロジックをdocument.documentElementへのdata-theme切替に置き換えるのが実用上の変更点になる。トグルのUI自体(track/knob/アイコン)は変更せずに使い回せる。
コード(コピーして使えます)
<div class="tm-wrap" id="tmWrap">
<button class="tm-toggle" id="tmToggle" role="switch" aria-checked="false" aria-label="ダークモード切替">
<span class="tm-track">
<span class="tm-knob" aria-hidden="true">
<svg class="tm-icon tm-sun" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="5" fill="currentColor"/>
<line x1="12" y1="2" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="19" x2="12" y2="22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="2" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="19" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<svg class="tm-icon tm-moon" viewBox="0 0 24 24" fill="none">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="currentColor"/>
<circle cx="17" cy="8" r="1.2" fill="#0d1f3a" opacity=".5"/>
<circle cx="14" cy="13" r="0.8" fill="#0d1f3a" opacity=".4"/>
<circle cx="19" cy="12" r="0.6" fill="#0d1f3a" opacity=".3"/>
</svg>
</span>
</span>
<span class="tm-label" id="tmLabel">ライト</span>
</button>
<div class="tm-card" id="tmCard">
<div class="tm-card-icon" id="tmCardIcon" aria-hidden="true">
<svg viewBox="0 0 36 36" fill="none">
<circle cx="18" cy="18" r="16" fill="currentColor" opacity=".15"/>
<circle cx="18" cy="14" r="5" fill="currentColor" opacity=".7"/>
<path d="M8 30c0-5.52 4.48-10 10-10s10 4.48 10 10" fill="currentColor" opacity=".5"/>
</svg>
</div>
<div class="tm-card-text">
<p class="tm-card-name" id="tmCardName">田中 太郎</p>
<p class="tm-card-sub" id="tmCardSub">テーマを切り替えてみよう</p>
</div>
</div>
</div>