How to Implement System-Aware Theme Switching in a Tauri App
Tauri applications can achieve seamless light, dark, and system-default theme switching by combining frontend window event listeners with Rust backend commands that persist user preferences and handle OS-level theme detection.
The lencx/ChatGPT repository demonstrates a production-ready implementation of theme switching that respects operating system preferences while allowing manual overrides. This guide examines the complete stack—from React hooks that monitor system changes to Rust configuration handlers that resolve theme states at startup.
Architecture Overview
The implementation spans three distinct layers that communicate through Tauri’s command system and configuration APIs:
- Frontend (React): Detects current window themes via
window.theme()and listens foronThemeChangedevents. The UI insrc/view/Titlebar.tsxprovides controls that invoke backend commands. - Backend (Rust): Handles persistence through the
set_themecommand insrc-tauri/src/core/cmd.rs, which writes to JSON configuration and triggers an application restart. - Configuration Layer: Manages the
AppConfstruct insrc-tauri/src/core/conf.rs, which maps stored string values (system,light,dark) to Tauri’sThemeenum and handles system theme detection using thedark_lightcrate.
Frontend Implementation
Detecting System Theme Changes
The useTheme custom hook in src/hooks/useTheme.tsx bridges Tauri’s window API with React state. It initializes the theme by querying the current window state and registers a listener for system-driven changes.
// src/hooks/useTheme.tsx
import { useState, useEffect } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
export default function useTheme() {
const [theme, setTheme] = useState<string | null>('light');
useEffect(() => {
let unlisten: Function;
(async () => {
const win = getCurrentWindow();
// Initialize with current window theme
setTheme(await win.theme() || '');
// Listen for OS theme changes
unlisten = await win.onThemeChanged(({ payload: newTheme }) => {
setTheme(newTheme);
});
})();
return () => {
unlisten?.();
};
}, []);
return theme;
}
This hook returns the active theme string (light or dark) derived from the window state, updating automatically when the OS switches themes while the app runs in system mode.
Building the Theme Toggle UI
The Titlebar component in src/view/Titlebar.tsx consumes the useTheme hook and renders memoized icons that cycle through the three available modes. When clicked, it invokes the set_theme command with the next mode in the sequence.
// src/view/Titlebar.tsx (excerpt)
const handleTheme = (theme: string) => {
invoke('set_theme', { theme });
};
const themeIcon = useMemo(() => {
switch (theme) {
case 'system':
return <ThemeSystem title="Light" action onClick={() => handleTheme('light')} />;
case 'light':
return <ThemeLight title="Dark" action onClick={() => handleTheme('dark')} />;
case 'dark':
return <ThemeDark title="System" action onClick={() => handleTheme('system')} />;
default:
return <ThemeSystem title="System" action onClick={() => handleTheme('system')} />;
}
}, [theme]);
The component cycles from system → light → dark → system, passing the selected value to the Rust backend for persistence.
Backend Persistence
The set_theme Command
When the frontend invokes set_theme, the Rust handler in src-tauri/src/core/cmd.rs updates the application configuration and triggers a restart to apply the theme globally across all windows.
// src-tauri/src/core/cmd.rs
#[command]
pub fn set_theme(app: AppHandle, theme: String) {
// Load and update configuration
let conf = AppConf::load(&app).unwrap();
conf.amend(serde_json::json!({"theme": theme}))
.unwrap()
.save(&app)
.unwrap();
// Restart to apply theme changes
app.restart();
}
The restart requirement ensures that the new theme takes effect during the window initialization phase, where Tauri applies the theme to the native window chrome before the frontend renders.
Resolving Theme at Startup
The AppConf struct in src-tauri/src/core/conf.rs provides the get_theme method that translates stored configuration strings into Tauri’s Theme enum. When the stored value is "system", it uses the dark_light crate to detect the current OS preference.
// src-tauri/src/core/conf.rs
pub fn get_theme(app: &AppHandle) -> Theme {
let theme = Self::load(app).unwrap().theme;
match theme.as_str() {
"system" => match dark_light::detect() {
dark_light::Mode::Dark => Theme::Dark,
_ => Theme::Light,
},
"dark" => Theme::Dark,
_ => Theme::Light,
}
}
This resolution happens during application setup, ensuring the window initializes with the correct theme before the frontend mounts.
Configuration Structure
The theme preference is stored as a string field in the application’s JSON configuration file, managed by the AppConf struct. The configuration supports three valid values:
system: Follows the OS setting usingdark_light::detect()light: Forces the light themedark: Forces the dark theme
The AppConf::amend() method merges updates into the existing configuration, while AppConf::save() writes the changes to disk before the restart occurs.
Summary
- Frontend detection relies on
getCurrentWindow().theme()andonThemeChangedevents to stay synchronized with system changes. - Backend persistence uses the
set_themecommand insrc-tauri/src/core/cmd.rsto write to JSON config and restart the app. - System theme resolution occurs in
src-tauri/src/core/conf.rsvia thedark_lightcrate, mapping OS detection to Tauri’sThemeenum. - Restart requirement ensures the theme applies to native window elements that cannot be changed at runtime.
Frequently Asked Questions
Does the application need to restart when switching themes?
Yes. According to the implementation in src-tauri/src/core/cmd.rs, the set_theme command calls app.restart() immediately after saving the configuration. This restart ensures that Tauri applies the new theme during window initialization, affecting the native window chrome and frame elements that cannot be hot-swapped at runtime.
How does the app detect live changes to the system theme?
When the app operates in system mode, the frontend hook useTheme in src/hooks/useTheme.tsx registers a listener via win.onThemeChanged(). This Tauri API emits events whenever the OS switches between light and dark modes, allowing the React component to update state and re-render without requiring a restart—provided the configuration remains set to system.
What Rust crate handles system theme detection?
The implementation uses the dark_light crate to detect the operating system’s current theme preference. In src-tauri/src/core/conf.rs, the get_theme function calls dark_light::detect() when the stored configuration equals "system", returning Theme::Dark or Theme::Light based on the OS mode.
Where is the user’s theme preference stored?
The theme string is persisted in a JSON configuration file managed by the AppConf struct located in src-tauri/src/core/conf.rs. The set_theme command updates this file using conf.amend() and conf.save(), writing the selected mode (system, light, or dark) to disk before triggering the application restart.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →