本文へスキップ

本サイトに light-dark() でダークモード対応してみた

夜にコーディングしながら自分のポートフォリオを開いたら、画面の眩しさで目が痛くなりました。それがきっかけで、AoiWeb(このサイト)をダークモードに対応させました。

使ったのは light-dark() というCSSの関数です。ライトとダークの色を1行で書き分けられて、メディアクエリを書くより見通しがいい。ただ、いきなりこの関数だけで済むわけではなく、土台の整備にかなり手間をかけました。

僕が進めた順番をそのまま書くので、同じようにダーク対応を考えている方の設計の参考になればと思います。

完成形:OSの外観設定に追従する

先に結果を出します。AoiWebのトップページは、OSの外観設定(ライト・ダーク)に合わせて、自動で色が切り替わるようになりました。

ライトモードのトップページ
ダークモードのトップページ

背景の青はほぼ反転し、モックアップのカードもダーク基調になりました。文字色やリンクの強調色も、背景に合わせて読みやすい色に調整しています。

ダークモードがあるだけで、サイト全体の雰囲気がガラッと変わりますね、、!時間帯や気分で切り替えられるのは、見ている側にとっても嬉しいポイントだと思います。

設計の方針:いきなり light-dark() から始めない

今回いちばん気をつけたのは、段階的に進めることです。既存のサイトに後からダークモードを足すとき、いきなり light-dark() を書き始めるとほぼ確実に色が崩れます。原因は、既存のCSSに散らばった色指定が 「具体的な色」のまま固定 されているからです。

  • Phase 1:色をSCSS変数からCSSカスタムプロパティに直す(土台作り)
  • Phase 2:各SCSSファイルを var() 経由に書き換える(参照の置き換え)
  • Phase 3:「役割」で命名する Semantic 層を挟み、light-dark() でダーク対応する(本番)

地味な作業ですが、Phase 1と2は先に終わらせておきます。すると Phase 3 で色を切り替える作業は、「変数の右辺を書き換えるだけ」の単純作業になります。コミットも細かく分けたので、途中で壊れてもすぐ戻せました。

Phase 1:色をカスタムプロパティとして定義する

もともとAoiWebの色は、Sass(CSSを拡張するメタ言語)で管理していました。使っていたのは $color-list という連想配列です。これだと色を変えたいときコンパイルし直しが必要で、ブラウザ上で色を切り替えるダークモードとは相性が悪いです。

そこで _root.scss:root セレクタを開き、Sassの @each ループを使いました。$color-list を全部CSSカスタムプロパティに展開します。

:root {
  @each $name, $value in $color-list {
    --color-#{$name}: #{$value};
  }
}

これで --color-main などのCSSカスタムプロパティが使えるようになりました。ブラウザ側から直接参照できます。この段階ではまだ色は何も変わっていません。土台を作っただけです。

Phase 2:既存ファイルを var() 経由に置き換える

次に、既存のSCSSファイルの $color-main のような Sass 変数の直接参照を書き換えました。すべて var(--color-main) というCSSカスタムプロパティ経由の参照にします。

// Before
.btn {
  color: $color-main;
  background-color: $color-bg-soft;
}

// After
.btn {
  color: var(--color-main);
  background-color: var(--color-bg-soft);
}

地味な作業ですが、ここを丁寧にやらないとダーク対応のときに置き換え漏れが起きます。AoiWebでは foundation/layout/module/page/ の順に進めました。ディレクトリごとにコミットを分けています。

1つのコミットで20ファイルを一気に書き換えるのではなく、6回に分けて少しずつコミット。途中で表示崩れがあっても、どのコミットが原因かすぐ分かります。

Phase 3-1:「役割」で命名する Semantic 層を挟む

ここからが本番です。--color-main--color-text-body「具体的な色」を表す名前 です。これだとライト用の値しか持てません。

そこで、--color-* を直接参照するのをやめて、「役割」を表す Semantic 層 を間に挟みます。背景なのか、前景なのか、ボーダーなのか、リンク色なのか。役割で命名したカスタムプロパティを新しく定義します。

:root {
  --foreground-base: var(--color-text-body);
  --foreground-heading: var(--color-text-heading);
  --foreground-muted: var(--color-text-meta);
  --background-base: var(--color-brightest);
  --background-soft: var(--color-bg-soft);
  --border-base: var(--color-border-light);
  --accent-link: var(--color-link);
}

名前のつけ方は、Tailwind CSS や shadcn/ui の語彙を参考にしました。foreground / background / border / accent といった呼び方です。「黒(具体)」ではなく「文章の前景色(役割)」と呼ぶことで、後からダーク用の色を別途渡しても破綻しません。

この時点でも色はまだ変わっていません。間に層を1枚挟んだだけです。

Phase 3-2:light-dark() でダークモード対応する

いよいよダークモード対応です。手を動かした内容は次の通りです。

  • color-scheme: light dark:root に宣言する
  • ダーク用の Primitive(--color-dark-*)を定義する
  • Semantic 変数の右辺を light-dark() で巻き直す

color-scheme を宣言することで、ブラウザに「このページはライトとダーク両方に対応している」と伝えます。これがないと light-dark() 関数自体が機能しません。

続いて、ダーク用の色を別の連想配列にまとめておきます。

$color-dark-list: (
  bg-base: #0d1a2a,
  bg-soft: #1e2f44,
  fg-base: #c8d2dc,
  fg-heading: #e8f4fd,
  fg-muted: #8892a0,
  border-base: #2a3a4d,
  link: #69f,
);

そしてこれを Phase 1 と同じように --color-dark-* として展開します。Semantic 層の右辺を light-dark() で書き直します。

:root {
  color-scheme: light dark;

  --foreground-base:
    light-dark(var(--color-text-body), var(--color-dark-fg-base));
  --background-base:
    light-dark(var(--color-brightest), var(--color-dark-bg-base));
  --border-base:
    light-dark(var(--color-border-light), var(--color-dark-border-base));
  --accent-link:
    light-dark(var(--color-link), var(--color-dark-link));
}

light-dark(明色, 暗色) は、OSの外観設定がライトなら第1引数、ダークなら第2引数を返す 関数です。これだけで prefers-color-scheme のメディアクエリを書かずに済みます。

light-darkがOSの外観設定で第1引数と第2引数を切り替える仕組み

ハマった話:body の参照を見落として全部ライト固定だった

ここまでやって「よし、確認してみるか」とOSをダークモードに切り替えたところ、背景が真っ白なままでした。文字色は変わるのに、画面全体は明るいまま。なぜか。

原因は foundation/_base.scss でした。body 要素の colorbackground-color が、Semantic 層を経由していませんでした。Primitive を直接参照したままだったんです。

// 直っていなかった箇所
body {
  color: var(--color-text-body);
  background-color: var(--color-brightest);
}

Phase 2 で var() 経由には変換していました。ただ Phase 3-1 で Semantic 層を作ったときに、body だけ放置していたんです。

// 修正後
body {
  color: var(--foreground-base);
  background-color: var(--background-base);
}

これで一気にダーク対応が効くようになりました!ページ全体の色を司る body ほど見落としやすい、というのが今回いちばんの学びです。

僕は確認するとき、まず「bodyの参照が Semantic に向いているか」を見るようにしています。

Phase 3-3:oklch() 相対色構文で派生色を作る

ここまででダーク対応は機能しています。が、もう一歩踏み込んだのが Phase 3-3 です。

ボタンには通常色のほかに、hover や active の派生色が必要です。ライト時は元色より少し暗く、ダーク時は元色より少し明るくしたい。これを oklch()(人間の視覚に近い色空間)の相対色構文で書きます。

:root {
  --background-button-active: light-dark(
    oklch(from var(--color-main) calc(l - 0.13) c h),
    oklch(from var(--color-main) calc(l + 0.06) c h)
  );
}

これは「--color-main の明度を 0.13 下げた色」という意味です。彩度(c)と色相(h)はそのまま使い回します。

oklch() の相対色構文がピンと来なくても、深く理解せずにそのままコピーして進めて問題ありません。

これを light-dark() と組み合わせると、派生色も1つの変数定義で両対応できます。ライト時は元色を暗く、ダーク時は元色を明るくする指定です。色を1色ずつ手動でカラーピッカーで合わせる必要がなくなり、ブランドカラーが変わっても自動で追従するのも嬉しいです!

検証ツールでダーク表示を確認する

OS設定をいちいち切り替えるのは面倒です。Chrome DevTools(検証ツール)には便利な機能があります。prefers-color-scheme を仮想的に再現できます。

使い方はシンプルです。

  • サイトを開いて、検証ツールを開く(macOSは Cmd + Option + I
  • Cmd + Shift + P でコマンドパレットを開く
  • 「Show Rendering」と入力して選択
  • 下部に出てくる Rendering タブを開く
  • 「Emulate CSS media feature prefers-color-scheme」を「dark」に切り替える
DevToolsのRenderingタブでprefers-color-schemeをdarkに設定

これで、OS設定はそのままにブラウザ表示だけダーク扱いに切り替わります。data-theme による手動切替を作る前の段階で、デザインの確認に重宝します。

僕もライト・ダークの両方で、文字の読みやすさやコントラストが保てているかを、ここで一通りチェックしました。

おわりに

AoiWebのダークモード対応を、Primitive・Semantic・light-dark() の3段階で進めた記録でした。

サイトの規模や既存CSSの書き方によっては、今回と同じ手順がそのまま当てはまるわけではないと思います。

次は data-theme を使った手動切替も試すつもりです。light-dark() だけでも、ぜひ一度試してみてください!

Contact

お気軽にご相談ください

X(Twitter)のDM、もしくは本サイトのCONTACTページからご連絡ください。
24時間以内に返信しますので、些細なことでもお気軽にご相談ください。