TECH · ダークモード実装

反転ではなく作り直す暗色設計

サンプルとコード

01

2テーマをCSS変数で設計する

単独で開く ↗

色を役割で名付け、テーマごとに値を差し替えます。明色では影で作っていた奥行きを、暗色では面の明度差で作り直しているのが要点です。

HTML
<div class="card">
  <h2>役割で名付けた変数</h2>
  <p>色は <code>--surface</code> や <code>--text-muted</code> のように役割で持ちます。<code>--gray-100</code> のような名前にすると、暗色テーマで「一番薄い灰色が一番濃い」という矛盾が起きます。</p>
  <div class="inset">
    沈んだ面は <code>--surface-2</code>。明色では地より暗く、暗色では地より明るい。同じ変数名で逆向きの値を持てるのが、役割で名付ける利点です。
  </div>
  <div class="row" style="margin-top:1rem">
    <input type="text" value="入力欄も追従します" aria-label="サンプル入力" />
    <button class="btn" type="button">ボタン</button>
    <span class="tag">タグ</span>
  </div>
</div>
CSS
/* 1) 変数は「色の名前」ではなく「役割」で名付ける。
      --gray-100 ではなく --surface。テーマを足すときに破綻しない。 */
:root {
  color-scheme: light;

  --bg:        #f7f8fb;  /* ページの地 */
  --surface:   #ffffff;  /* カードなど持ち上がった面 */
  --surface-2: #eef1f6;  /* さらに一段沈んだ面 */
  --border:    #e2e6ee;
  --text:      #1b2030;
  --text-muted:#5a6478;
  --accent:    #2563eb;
  --accent-bg: #eaf1fe;
  /* 明色では影で奥行きを作る */
  --shadow: 0 1px 2px rgba(27, 32, 48, .06), 0 8px 24px -16px rgba(27, 32, 48, .3);
}

/* 2) 暗色は「反転」ではない。影が効かなくなるぶん、
      面の明度差そのもので階層を作り直す。 */
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;

    --bg:        #0f131b;
    --surface:   #171d28;  /* 地より明るい=手前 */
    --surface-2: #1f2733;
    --border:    #2b3444;
    --text:      #e7ecf4;
    --text-muted:#98a2b5;
    --accent:    #6ea8ff;  /* 暗背景では彩度を落として明度を上げる */
    --accent-bg: #17233a;
    /* 暗色の影はほぼ見えない。境界線に仕事を移す */
    --shadow: 0 0 0 1px rgba(255, 255, 255, .04);
  }
}

/* 3) color-scheme を宣言すると、フォーム部品・スクロールバー・
      既定の選択色までブラウザが揃えてくれる。これを書かないと
      入力欄だけ明色のまま残る。 */
AIへの指示文
CSS変数で明色と暗色の2テーマを設計してください。
要件:
- 変数は色名ではなく役割で名付ける(--surface、--text-muted など)。
  --gray-100 のような名前は暗色テーマで矛盾する、と理由をコメントで書く。
- :root に明色を定義し、@media (prefers-color-scheme: dark) で値だけ差し替える。
- 明色は影で奥行きを作り、暗色では影をほぼ消して面の明度差と境界線で表す。
  --shadow 変数の値をテーマごとに変えて実現する。
- :root に color-scheme を必ず宣言する。書かないとフォーム部品や
  スクロールバーが明色のまま残る、と理由をコメントで書く。
- 入力欄・ボタン・タグを1つずつ置いて、追従を目で確認できるようにする。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
02

light-dark() で1行にまとめる

単独で開く ↗

2つの値を1つの宣言に書けるので、変数定義がおよそ半分になります。未対応ブラウザ向けに @supports not で受け皿を用意しています。

HTML
<div class="card">
  <h2>2つの値を1行で持つ</h2>
  <p><code>light-dark(明色, 暗色)</code> と書くと、<code>color-scheme</code> の状態に応じてブラウザが選びます。<code>@media (prefers-color-scheme: dark)</code> のブロックを丸ごと書かずに済むので、変数定義がおよそ半分になります。</p>
  <p>使う条件は <code>color-scheme: light dark</code> を宣言しておくこと。宣言が無いと常に明色側が選ばれます。手動切替と組み合わせるときも、<code>color-scheme</code> を上書きするだけで全変数が追従します。</p>
</div>
CSS
/* light-dark() は「いまのカラースキームに応じて2つの値から選ぶ」関数。
   @media を2回書かずに済むので、変数定義が半分の行数になる。
   使う条件は color-scheme が宣言されていること。 */
:root {
  color-scheme: light dark;   /* 両対応であることを宣言する */

  --bg:        light-dark(#f7f8fb, #0f131b);
  --surface:   light-dark(#ffffff, #171d28);
  --border:    light-dark(#e2e6ee, #2b3444);
  --text:      light-dark(#1b2030, #e7ecf4);
  --text-muted:light-dark(#5a6478, #98a2b5);
  --accent:    light-dark(#2563eb, #6ea8ff);
}

/* 未対応ブラウザ向けの受け皿。light-dark() が解釈できないと
   宣言ごと無効になるので、先に単色を書いておけば残る。 */
@supports not (color: light-dark(#000, #fff)) {
  :root { --bg: #f7f8fb; --surface: #fff; --border: #e2e6ee;
          --text: #1b2030; --text-muted: #5a6478; --accent: #2563eb; }
  @media (prefers-color-scheme: dark) {
    :root { --bg: #0f131b; --surface: #171d28; --border: #2b3444;
            --text: #e7ecf4; --text-muted: #98a2b5; --accent: #6ea8ff; }
  }
}
AIへの指示文
light-dark() 関数を使って2テーマの変数定義をまとめてください。
要件:
- :root に color-scheme: light dark を宣言する。これが無いと常に明色側が選ばれる。
- 各変数を --bg: light-dark(明色, 暗色) の形で1行にまとめる。
- @supports not (color: light-dark(#000, #fff)) で受け皿を用意し、
  未対応環境では従来の @media 方式にフォールバックする。
- なぜ受け皿が要るのか(未対応だと宣言ごと無効になる)をコメントで書く。
03

3状態のテーマ切替

単独で開く ↗

システム追従・ライト固定・ダーク固定の3状態です。2状態にすると「システムに戻す」ができなくなります。localStorage は例外を投げうるので必ず try/catch で包みます。

HTML
<div class="switch" role="group" aria-label="テーマの切替">
  <button type="button" data-theme-value="auto" aria-pressed="true">システム</button>
  <button type="button" data-theme-value="light" aria-pressed="false">ライト</button>
  <button type="button" data-theme-value="dark" aria-pressed="false">ダーク</button>
</div>

<div class="card">
  <h2>3状態にするのが要点</h2>
  <p>明・暗の2状態にすると「システム設定に戻す」ができなくなります。既定は <code>auto</code> にしておき、利用者が明示的に選んだときだけ <code>data-theme</code> を固定してください。</p>
</div>

<p class="state" id="state">現在: システム設定に追従</p>
CSS
/* テーマは data-theme 属性で持つ。値は auto / light / dark の3つ。
   auto のときだけ @media にシステム設定を判断させる。 */
:root {
  color-scheme: light;
  --bg: #f7f8fb; --surface: #fff; --border: #e2e6ee;
  --text: #1b2030; --text-muted: #5a6478; --accent: #2563eb;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #0f131b; --surface: #171d28; --border: #2b3444;
    --text: #e7ecf4; --text-muted: #98a2b5; --accent: #6ea8ff;
  }
}
/* 手動で暗色を選んだときは、システム設定より優先させる */
:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0f131b; --surface: #171d28; --border: #2b3444;
  --text: #e7ecf4; --text-muted: #98a2b5; --accent: #6ea8ff;
}

/* 切替の瞬間に色が飛ぶのを抑える。ただし
   「動きを減らす」設定では即座に切り替える。 */
body { transition: background-color .2s ease, color .2s ease; }
@media (prefers-reduced-motion: reduce) { body { transition: none; } }

.switch {
  display: inline-flex; gap: 2px; padding: 3px;
  background: color-mix(in srgb, var(--text) 8%, transparent);
  border-radius: 999px;
}
.switch button {
  font: inherit; font-size: .84rem; line-height: 1.7;
  padding: .35rem .9rem; border: 0; border-radius: 999px;
  background: none; color: var(--text-muted); cursor: pointer;
}
/* 現在の状態は aria-pressed で持ち、CSS はそれを見て塗る */
.switch button[aria-pressed="true"] {
  background: var(--surface); color: var(--accent); font-weight: 700;
}
.switch button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
JavaScript
const KEY = "theme";
const root = document.documentElement;
const buttons = [...document.querySelectorAll("[data-theme-value]")];
const state = document.getElementById("state");

// localStorage は必ず try/catch で包む。プライベートモード、
// 保存を禁止した設定、サンドボックス化された iframe では例外を投げる。
const store = {
  get() { try { return localStorage.getItem(KEY); } catch { return null; } },
  set(v) { try { v ? localStorage.setItem(KEY, v) : localStorage.removeItem(KEY); } catch {} },
};

function apply(value) {
  if (value === "auto") root.removeAttribute("data-theme");
  else root.setAttribute("data-theme", value);

  buttons.forEach((b) =>
    b.setAttribute("aria-pressed", String(b.dataset.themeValue === value)),
  );

  const label = { auto: "システム設定に追従", light: "ライト固定", dark: "ダーク固定" };
  state.textContent = "現在: " + label[value];
}

buttons.forEach((b) =>
  b.addEventListener("click", () => {
    const v = b.dataset.themeValue;
    store.set(v === "auto" ? null : v);
    apply(v);
  }),
);

apply(store.get() ?? "auto");
AIへの指示文
テーマを手動で切り替える仕組みを作ってください。
要件:
- 状態は auto / light / dark の3つ。2状態にすると
  「システム設定に戻す」ができなくなる、と理由をコメントで書く。
- auto のときは html の data-theme 属性を外し、@media に判断させる。
  明示的に選んだときだけ data-theme を設定する。
- CSS は :root:not([data-theme="light"]) を @media 内に書いて、
  手動指定がシステム設定より優先されるようにする。
- 選択は localStorage に保存する。ただし必ず try/catch で包む。
  プライベートモードやサンドボックス化された iframe では例外を投げるため。
- ボタンの現在状態は aria-pressed で持ち、CSS はそれを見て塗る。
- 切替時の色の飛びを transition で抑え、prefers-reduced-motion では切る。
04

暗色で崩れるもの

単独で開く ↗

影・ロゴ・純白と純黒。この3つは明色の設計をそのまま持ち込むと必ず破綻します。それぞれの直し方を並べました。

HTML
<div class="card">
  <h2>影は消える。境界線に仕事を移す</h2>
  <p>明色では影が奥行きを作りますが、暗背景の上に黒い影を落としてもほぼ見えません。暗色では影を消し、代わりに境界線と面の明度差で階層を表します。</p>
  <div class="demo-row">
    <div class="elevated">持ち上がった面</div>
    <div class="elevated">もう一枚</div>
  </div>
</div>

<div class="card">
  <h2>明色前提のロゴは浮く</h2>
  <p>白地に置く前提で作られたロゴを暗背景にそのまま置くと、そこだけが光って見えます。明度と彩度をわずかに落とすと馴染みます。写真には当てないでください。</p>
  <div class="demo-row">
    <span class="logo-box">
      <svg width="104" height="24" viewBox="0 0 104 24" role="img" aria-label="サンプルロゴ">
        <rect x="0" y="4" width="16" height="16" rx="5" fill="#2563eb"></rect>
        <text x="24" y="17" font-family="Inter, sans-serif" font-size="14" font-weight="700" fill="#1b2030">Console</text>
      </svg>
    </span>
  </div>
</div>

<div class="card">
  <h2>純白と純黒を使わない</h2>
  <p class="contrast-note">暗背景に <code>#fff</code> の本文を置くと眩しく、長文で目が疲れます。<code>#e7ecf4</code> のように少し落とすだけで読みやすさが変わります。背景も <code>#000</code> ではなく <code>#0f131b</code> のように色味を持たせたほうが、明るい部屋でも沈みません。</p>
</div>
CSS
/* 1) 影は暗色でほとんど見えない。奥行きは境界線と面の明度差に移す。 */
.elevated {
  background: var(--surface);
  border-radius: 12px;
  padding: .9rem 1rem;
  box-shadow: light-dark(
    0 8px 24px -14px rgba(27, 32, 48, .45),
    none
  );
  border: 1px solid light-dark(transparent, #2b3444);
}

/* 2) 明色前提の画像・ロゴは暗背景で浮く。
      明度を少し落とし、彩度をわずかに下げると馴染む。
      写真には当てないこと(人物の肌が濁る)。 */
.logo-box { padding: .7rem 1rem; border-radius: 10px; background: #fff; }
@media (prefers-color-scheme: dark) {
  .logo-box { background: #eef1f6; }
  .logo-box svg { filter: brightness(.92) saturate(.9); }
}

/* 3) 図やイラストをテーマごとに差し替えるなら picture が確実。
      CSS の filter で反転させると、文字まで反転して読めなくなる。 */
/* 4) 純白と純黒は使わない。#fff の文字は暗背景で眩しく、
      #000 の背景は明るい部屋で沈む。少し寄せた値にする。 */
.contrast-note {
  border-left: 3px solid var(--accent);
  padding-left: .9rem;
  color: var(--text-muted);
  line-height: 1.85;
  font-size: .9rem;
}
AIへの指示文
暗色テーマで崩れやすい要素の対処をまとめたサンプルを作ってください。
要件:
- 影: 暗背景では黒い影がほぼ見えない。light-dark() で暗色時は影を消し、
  代わりに境界線を出して階層を表す。
- ロゴ: 明色前提の図版は暗背景で浮く。背景を明るい面に敷き、
  filter: brightness(.92) saturate(.9) でわずかに落として馴染ませる。
  写真には当てないこと(肌が濁る)を明記する。
- 図やイラストの差し替えは picture を使う。CSS の filter による反転は
  文字まで反転して読めなくなるので使わない。
- 純白と純黒を使わない。暗背景の #fff の本文は眩しく、#000 の背景は
  明るい部屋で沈む。#e7ecf4 と #0f131b のように寄せた値にする。

この技術について

ダークモードは色を反転させることではありません。ここを取り違えると、実装は必ず途中で破綻します。

理由は影にあります。明色の画面では、白いカードの下に落ちる薄い影が「手前にある」ことを伝えていました。同じ影を暗い背景の上に落としても、黒の上の黒はほとんど見えません。奥行きを作っていた道具が、暗色では機能しなくなるわけです。だから暗色は、反転ではなく作り直しになります。

順序が決まっている

設計の手順は3つで、順番も決まっています。

まず役割で名付けた変数を用意する。--gray-100 ではなく --surface--text-muted--border。色名で名付けると、明色で「一番薄い灰色」だったものが暗色では一番濃くなり、名前と実体が矛盾します。

次にテーマごとに値を差し替える。ここで影の扱いも一緒に切り替えます。明色では --shadow に実際の影を、暗色では none に近い値を入れ、代わりに境界線を出す。面の明度で階層を表すよう作り直します。

最後に color-scheme を宣言する。これを書かないと、本文だけ暗くなって入力欄とスクロールバーが明色のまま残ります。1行で効果が大きい割に、忘れられやすいところです。

明るい側を先に全部定義し、暗い側は差分だけを後から上書きする 正しい順序 ① :root { --bg: #fff } まず明るい側を全部定義 ② @media (dark) { 上書き } 暗い側は差分だけ ③ [data-theme] { 再上書き } 順序を逆にすると @media (dark) { … } :root { --bg: #fff } 後から書いたほうが勝つので 暗い側が常に打ち消される 同じ詳細度なら、あとに書いたルールが勝つ。ダークモードが効かない原因の多くはここ
上書きの向きを一方向に固定すると、値がどこで決まったかを1か所ずつ追えます。差分だけを書くので、色を1つ足したときの修正も1行で済みます。

2つの値を1行で持つ

light-dark() を使うと、@media のブロックを丸ごと書かずに済みます。--bg: light-dark(#f7f8fb, #0f131b) と書くだけで、color-scheme の状態に応じてブラウザが選んでくれる。変数定義の行数がおよそ半分になります。

条件は color-scheme: light dark を先に宣言しておくことです。宣言が無いと常に明色側が選ばれます。そして未対応ブラウザでは宣言ごと無効になるので、@supports not で従来の書き方を受け皿として残しておいてください。

light-dark() は1つの宣言に明暗2つの値を持たせる --bg: light-dark(#ffffff, #14120f); 明るいとき 暗いとき 1つの宣言に2つの値が入る color-scheme: light dark; これが無いと light-dark は効かない 従来の書き方 :root { --bg: #ffffff } @media (prefers-color-scheme: dark) { :root { --bg: #14120f } } 色を1つ足すたびに2か所を触る。片方だけ直す事故が起きる
宣言が離れていると、片方だけ直したことに気づけません。1行にまとめると、その事故が起こる場所自体が無くなります。color-scheme の1行を忘れないでください。

2状態ではなく3状態にする

手動切替を付けるとき、明と暗の2状態にするのはよくある失敗です。一度でも手動で選ぶと、システム設定へ戻す手段が消えます

既定は auto(システム追従)にして、利用者が明示的に選んだときだけ data-theme を固定する。この3状態が正解です。CSSは @media (prefers-color-scheme: dark) の中に :root:not([data-theme="light"]) と書けば、手動指定がシステム設定に勝つ形になります。

保存に localStorage を使うときは、必ず try/catch で包んでください。プライベートモード、保存を禁止した設定、サンドボックス化された iframe では例外を投げます。包まずに書くと、そこでスクリプト全体が止まります。

OSに従う・明るい固定・暗い固定の3状態にする OSに従う 既定・保存しない 明るい固定 data-theme=light 暗い固定 data-theme=dark 3つを順に巡回させる 2状態だと「OSに従う」へ戻れない。夜だけ暗くしたい人の設定を、こちらが奪うことになる
実装は増えますが、増えるのは「保存しない」という状態を1つ持つだけです。切り替えたことがない利用者を、勝手にどちらかへ固定しないための一手間になります。

ちらつきを消す

実装した人が最後に気づくのが、読み込み直後に一瞬だけ明色が見える現象です。原因は単純で、保存したテーマを読んで属性を設定する処理が、CSSの適用より後になっているからです。

この処理だけは <head> の中で同期的に実行してください。body の末尾や遅延読み込みのモジュールに置くと、必ずちらつきます。数行のインラインスクリプトを <head> に置くのは、ここでは正しい選択です。

テーマの決定を head の同期スクリプトで行い、最初の描画から正しい色にする head で先に決める <head> script(同期) <body> 描画 最初の1フレームから正しい色 body の最後で決める <body> 描画(明るい) ← ここで白く光る script → 暗くする 一瞬まぶしい。夜に見る人には応える 読み込みを遅らせない小さな同期スクリプトは、ここでは正当な選択になる
非同期にすると必ず光ります。描画より前に確定させる必要があるので、ここだけは deferasync を付けません。数行なので実行時間も問題になりません。

使いどころとつまずきどころ

向いている場面

  • 長時間読ませる記事やドキュメント
  • 夜間に使われる管理画面やダッシュボード
  • OSのテーマ設定に合わせたい一般向けサイト
  • 目に負担をかけたくない読書・執筆ツール

つまずきやすい点

  • 色を反転させるだけでは影と奥行きが失われる
  • color-scheme を書かないとフォームやスクロールバーが明色のまま残る
  • 切替時に一瞬明色が見える(読み込み順の問題)
  • 画像やロゴが暗背景で浮く。明度を少し落とす必要がある

よくある質問

色を反転させるだけではだめですか。

だめです。明色では影が奥行きを作りますが、暗背景に黒い影を落としてもほとんど見えません。暗色では影を消し、面の明度差と境界線で階層を作り直す必要があります。反転しただけの画面が平坦に見えるのはこれが理由です。

color-scheme は書かないとどうなりますか。

フォーム部品、スクロールバー、テキスト選択色などがブラウザ既定の明色のまま残ります。本文だけ暗くなって入力欄が真っ白、という状態になるので、テーマを切り替えるなら必ず宣言してください。1行で効果は大きいところです。

切替の一瞬だけ明色が見えてしまいます。

テーマの復元がCSSの適用より後になっているためです。localStorage を読んで data-theme を設定する処理は、head の中で同期的に実行してください。body の末尾やモジュールの遅延読み込みに置くと、必ず一瞬ちらつきます。

変数名はどう決めるのがよいですか。

役割で名付けてください。--gray-100 のような名前は明色では「一番薄い灰色」ですが、暗色では一番濃くなり、名前と実体が矛盾します。--surface、--text-muted、--border のように使い道で名付ければ、テーマを何種類足しても破綻しません。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術