OS のダークモードを検知して CSS を切り替える

prefers-color-scheme と CSS 変数で配色を切り替え、matchMedia か theme() / onThemeChanged で OS のテーマ変更に追従する。真偽値の判定、テーマ固定、起動時のチラつき対策も示す。

フロントエンド 対象: Tauri 2.x 更新日: 読了目安: 約8分 front-010
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. CSS 変数で配色を一元管理する
  4. matchMedia で検知する(Web 標準)
  5. Tauri の window API で検知する
  6. ダークモードかどうかを判定するだけなら
  7. 2. バックエンドから実装する (Rust)
  8. tauri.conf.json でテーマを固定する
  9. Rust から取得・変更する
  10. Rust 側で判定し、OS の変更を受け取る
  11. 起動時のチラつき対策
  12. 動作確認
  13. よくあるエラーと対処法
  14. theme() が null を返す
  15. Permissions associated with this command: core:window:allow-set-theme という趣旨のエラー
  16. @media の判定と theme() が食い違う
  17. Linux で OS のダークモードに反応しない
  18. OS ごとの違いと注意点
  19. 関連レシピ

OS のダークモード設定に合わせて配色を切り替え、実行中に設定が変わった瞬間にも追従させる方法を紹介します。土台は CSS の prefers-color-scheme で、JS で検知するなら Web 標準の matchMedia、Tauri のウィンドウが認識しているテーマを使うなら getCurrentWindow().theme() / onThemeChanged です。あわせて tauri.conf.json でのテーマ固定と、起動直後に白いウィンドウが一瞬見えるチラつきの対策も扱います。プラグインは不要です。

前提条件

theme() と onThemeChanged に必要な core:window:allow-theme と core:event:allow-listen は core:default に含まれています。テーマを上書きする setTheme() を使う場合だけ、capabilities/default.json に core:window:allow-set-theme を追加してください。

1. フロントエンドから実装する (TypeScript)

CSS 変数で配色を一元管理する

色は :root の CSS 変数にまとめます。color-scheme も指定すると、スクロールバーやフォーム部品の既定色も WebView がテーマに合わせます。

:root {
  color-scheme: light dark;
  --bg: #ffffff; --fg: #1f1f1f; --accent: #2563eb;
}
@media (prefers-color-scheme: dark) {
  :root { --bg: #1e1e1e; --fg: #e8e8e8; --accent: #60a5fa; }
}
/* JS で明示的に切り替えるときはこちらが優先される */
:root[data-theme="light"] { --bg: #ffffff; --fg: #1f1f1f; --accent: #2563eb; }
:root[data-theme="dark"]  { --bg: #1e1e1e; --fg: #e8e8e8; --accent: #60a5fa; }

html, body { margin: 0; background: var(--bg); color: var(--fg); }

html にも背景色を付けるのがポイントです。body だけだと、ページ末尾より下の余白が WebView の既定色(白)で塗られることがあります。

matchMedia で検知する(Web 標準)

const mql = window.matchMedia('(prefers-color-scheme: dark)');
export const currentTheme = () => (mql.matches ? 'dark' : 'light');
export function watchTheme(cb: (t: 'light' | 'dark') => void) {
  const h = (e: MediaQueryListEvent) => cb(e.matches ? 'dark' : 'light');
  mql.addEventListener('change', h);
  return () => mql.removeEventListener('change', h);
}

Tauri の window API で検知する

theme() はウィンドウが認識しているテーマ('light' | 'dark' | null)を返し、onThemeChanged は tauri://theme-changed イベントで変更を通知します。テーマを固定した場合は固定値が返ります。

import { getCurrentWindow, type Theme } from '@tauri-apps/api/window';

const win = getCurrentWindow();

function applyTheme(theme: Theme | null) {
  document.documentElement.dataset.theme = theme ?? 'light'; // null は判定不能
}

export async function setupTheme() {
  applyTheme(await win.theme());
  return win.onThemeChanged(({ payload }) => {
    console.log('theme changed:', payload);
    applyTheme(payload);
  }); // 戻り値の unlisten を画面破棄時に呼ぶ
}

// ユーザー設定で上書き(allow-set-theme が必要)。null で OS 追従に戻る
export const forceTheme = (t: Theme | null) => win.setTheme(t);

data-theme 属性に書き出す方式にすると、CSS は属性だけを見ればよく、判定元を差し替えてもスタイルを触らずに済みます。

ダークモードかどうかを判定するだけなら

グラフや画像の配色を JS で選ぶときのように、「今ダークか」を真偽値で知りたいだけの場面もあります。方法ごとの違いは次のとおりです。

方法受け取り方権限theme を固定したとき
matchMedia(...).matches同期不要環境によって OS 側の値(後述)
getCurrentWindow().theme()非同期core:default に含まれる固定した値
Rust の theme()(2 章)Rust で同期不要固定した値
import { getCurrentWindow } from '@tauri-apps/api/window';

// 同期で読めるので、スクリプトの先頭で初期値を決めるのに向く
const darkByCss = window.matchMedia('(prefers-color-scheme: dark)').matches;
// ウィンドウのテーマ設定も反映した値。null(判定不能)はライト扱いにする
const darkByWindow = (await getCurrentWindow().theme()) === 'dark';
console.log({ darkByCss, darkByWindow });

2. バックエンドから実装する (Rust)

tauri.conf.json でテーマを固定する

theme の値は大文字小文字を区別しません。backgroundColor も指定すると、WebView 描画前のウィンドウ背景がその色になります。

{
  "app": {
    "windows": [
      { "label": "main", "theme": "dark", "backgroundColor": "#1e1e1e" }
    ]
  }
}

Rust から取得・変更する

use tauri::Theme;

#[tauri::command]
fn current_theme(window: tauri::WebviewWindow) -> Result<String, String> {
    window.theme().map(|t| t.to_string()).map_err(|e| e.to_string()) // "light" / "dark"
}

#[tauri::command]
fn set_dark(window: tauri::WebviewWindow, dark: bool) -> Result<(), String> {
    let theme = if dark { Some(Theme::Dark) } else { None }; // None で OS 追従
    window.set_theme(theme).map_err(|e| e.to_string())
}

invoke_handler(tauri::generate_handler![current_theme, set_dark]) に登録し、invoke('set_dark', { dark: true }) で呼びます。Rust でウィンドウを組み立てるなら WebviewWindowBuilder の .theme(Some(Theme::Dark)) と .background_color(...) が同じ役割です。

Rust 側で判定し、OS の変更を受け取る

トレイアイコンを明るい版と暗い版で切り替えるなど、ページを待たずに判定したいときは Rust で theme() を読み、変更は on_window_event の WindowEvent::ThemeChanged で受け取ります。Theme は将来値が増えうる型なので、match で Light と Dark だけを書くと網羅していないというコンパイルエラーになります。真偽値にするなら matches! が簡単です。ThemeChanged はテーマを固定していないウィンドウにだけ届き、Linux では届きません(JS の onThemeChanged も同じです)。

use tauri::{Manager, WindowEvent};

/// ダークなら true。取得に失敗したときはライト扱い
fn is_dark(window: &tauri::WebviewWindow) -> bool {
    matches!(window.theme(), Ok(tauri::Theme::Dark))
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            if let Some(main) = app.get_webview_window("main") {
                println!("起動時のテーマ: {}", if is_dark(&main) { "dark" } else { "light" });
            }
            Ok(())
        })
        .on_window_event(|window, event| {
            if let WindowEvent::ThemeChanged(theme) = event {
                println!("{} のテーマが {theme} に変わった", window.label());
            }
        })
        .invoke_handler(tauri::generate_handler![current_theme, set_dark])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

起動時のチラつき対策

ダークテーマでも起動直後にウィンドウが白く見え、CSS が当たった瞬間に暗くなることがあります。ネイティブウィンドウと WebView の初期描画色が CSS より先に決まるためです。対策は、backgroundColor をダーク時の --bg と同じ色にする、html 要素にも背景色を付ける、それでも足りなければ "visible": false で起動して初期化後に getCurrentWindow().show() を呼ぶ(ウィンドウを表示・非表示にする)、の順に足していきます。

動作確認

npm run tauri dev で起動し、アプリを表示したまま OS の設定を切り替えます(Windows: 設定 → 個人用設定 → 色、macOS: システム設定 → 外観、GNOME: gsettings set org.gnome.desktop.interface color-scheme prefer-dark)。切り替えた瞬間に背景色が変わり、コンソールに theme changed: dark と出れば成功です。theme を固定した場合は OS 設定を変えても theme() の値もタイトルバーの色も変わりません。

よくあるエラーと対処法

theme() が null を返す

判定できない環境では null になります。そのまま data-theme に入れると "null" という文字列になって CSS が効かないので、必ず既定値にフォールバックしてください。

Permissions associated with this command: core:window:allow-set-theme という趣旨のエラー

setTheme() に必要な権限は core:default に含まれていません。capabilities に追加してください。

@media の判定と theme() が食い違う

theme を固定しても、WebView の prefers-color-scheme は環境によって OS 設定側を返すことがあります。theme() の結果を data-theme に書き出し、CSS はその属性だけを見るように一本化してください。

Linux で OS のダークモードに反応しない

webkit2gtk は GTK のテーマ設定から prefers-color-scheme を決めるため、GNOME 以外や color-scheme 未設定の環境では常にライト扱いになることがあります。set_theme で明示指定するか、アプリ内にテーマ設定を用意するのが現実的です。

OS ごとの違いと注意点

  • Windows: theme の指定はタイトルバーの配色にも反映されます。backgroundColor のアルファ値は一部バージョンで無視されます。
  • macOS: theme() / set_theme は 10.14 以降で動作し、ウィンドウ単位ではなくアプリ全体に適用されます。
  • Linux: set_theme はアプリ全体に効きます。tauri.conf.json の theme は「Windows と macOS 10.14+ で実装」と明記されており、Linux では効かない前提で設計してください。
  • onThemeChanged の unlisten を SPA の画面遷移時に呼ばないと、同じ処理が複数回走ります。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する