# How to Implement System-Aware Theme Switching in a Tauri App

> Implement system-aware theme switching in your Tauri app. Combine frontend events and Rust commands to offer light, dark, or system-default themes for a better user experience.

- Repository: [lencx/ChatGPT](https://github.com/lencx/ChatGPT)
- Tags: how-to-guide
- Published: 2026-03-06

---

**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 for `onThemeChanged` events. The UI in [`src/view/Titlebar.tsx`](https://github.com/lencx/ChatGPT/blob/main/src/view/Titlebar.tsx) provides controls that invoke backend commands.
- **Backend (Rust)**: Handles persistence through the `set_theme` command in [`src-tauri/src/core/cmd.rs`](https://github.com/lencx/ChatGPT/blob/main/src-tauri/src/core/cmd.rs), which writes to JSON configuration and triggers an application restart.
- **Configuration Layer**: Manages the `AppConf` struct in [`src-tauri/src/core/conf.rs`](https://github.com/lencx/ChatGPT/blob/main/src-tauri/src/core/conf.rs), which maps stored string values (`system`, `light`, `dark`) to Tauri’s `Theme` enum and handles system theme detection using the `dark_light` crate.

## Frontend Implementation

### Detecting System Theme Changes

The `useTheme` custom hook in [`src/hooks/useTheme.tsx`](https://github.com/lencx/ChatGPT/blob/main/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.

```typescript
// 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`](https://github.com/lencx/ChatGPT/blob/main/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.

```typescript
// 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`](https://github.com/lencx/ChatGPT/blob/main/src-tauri/src/core/cmd.rs) updates the application configuration and triggers a restart to apply the theme globally across all windows.

```rust
// 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`](https://github.com/lencx/ChatGPT/blob/main/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.

```rust
// 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 using `dark_light::detect()`
- `light`: Forces the light theme
- `dark`: 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()` and `onThemeChanged` events to stay synchronized with system changes.
- **Backend persistence** uses the `set_theme` command in [`src-tauri/src/core/cmd.rs`](https://github.com/lencx/ChatGPT/blob/main/src-tauri/src/core/cmd.rs) to write to JSON config and restart the app.
- **System theme resolution** occurs in [`src-tauri/src/core/conf.rs`](https://github.com/lencx/ChatGPT/blob/main/src-tauri/src/core/conf.rs) via the `dark_light` crate, mapping OS detection to Tauri’s `Theme` enum.
- **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`](https://github.com/lencx/ChatGPT/blob/main/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`](https://github.com/lencx/ChatGPT/blob/main/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`](https://github.com/lencx/ChatGPT/blob/main/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`](https://github.com/lencx/ChatGPT/blob/main/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.