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

AoiWeb(このサイト)をダークモードに対応させました。

使ったのは light-dark() というCSSの関数です。ライトとダークの色を1行で書き分けられて、メディアクエリよりも見通しがいい。とはいえ、いきなりこの関数だけで完結するわけではなく、土台の整備に手間をかけました。今回は、その実装プロセスを順を追って紹介します。

同じように「サイトをダーク対応させたい」と考えている方の、設計の参考になれば嬉しいです。

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

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

背景の青がほぼ反転し、モックアップ部分のカードもダーク基調に変わっています。文字色やリンクの強調色も、それぞれの背景に合わせて読みやすい色に調整されています。

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

今回の対応で一番重視したのは「段階的にやる」ということです。既存のサイトに後からダークモードを追加するとき、いきなり light-dark() を書き始めると、ほぼ確実に色が崩れます。なぜなら、既存のCSSに散らばった色指定が 「具体的な色」のまま固定 されているからです。

そこで今回は、3つのフェーズに分けて進めました。

  • 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--color-text-body--color-bg-soft といった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() でダークモード対応する

いよいよダークモード対応です。やることは3つあります。

  • 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 のメディアクエリを書かずに済みます。

ハマった話: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)
  );
}

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

これを 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」に切り替える

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

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

まとめ:見えない品質へのこだわり

ダークモード対応は、単に「色を反転させる」作業ではなく、サイトの色設計そのものを見直す機会になりました。

ダークモードを使用している訪問者が「目が疲れない」と感じさせるためにも、やってよかったです。

Contact

お気軽にご相談ください

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