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 の画面遷移時に呼ばないと、同じ処理が複数回走ります。
