Let's Connect

A clean desk with a laptop and a small status indicator, representing a background desktop app living in the system tray

Getting a Tauri system tray working in v2 trips up almost everyone, because the API moved entirely between v1 and v2 and most tutorials you find still show the old `SystemTray::new()` builder that no longer compiles. In v2 there is no top-level system-tray struct: you build the icon with `TrayIconBuilder` inside the `setup` hook, attach a `Menu` of `MenuItem` entries to it, and wire two separate handlers — `on_menu_event` for menu clicks and `on_tray_icon_event` for clicks on the icon itself. I hit this head-on building a Laragon-style dev manager that has to live in the tray and stay running while it watches local services. This walks through the exact wiring: a tray icon with a menu, left-click to toggle the window, and closing the window minimizing to the tray instead of killing the process.

Why do v1 tray tutorials no longer compile?

In Tauri v1 you created a `SystemTray`, set it on the builder with `.system_tray(...)`, and registered an `on_system_tray_event` closure on the app builder. All of that is gone in v2. Tray support now lives in the `tray` and `menu` modules, the menu is a first-class object you build with `MenuItem`, and the tray is constructed at runtime in `setup` rather than declaratively on the builder. If you paste a v1 snippet you will get `unresolved import` errors on `SystemTray` and `SystemTrayEvent`, which is the giveaway that the guide predates the v2 release.

There is one Cargo gate to clear first. Tray support is behind a feature flag, so the icon never appears if you skip it. Enable `tray-icon` on the `tauri` dependency and, on Linux, make sure `libayatana-appindicator` is installed on the target machine or the tray simply will not render.

src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }

# The image crate is only needed if you build the icon from raw bytes
# at runtime. If you rely on the bundle icon (the common case), you do
# not need it — Tauri reads the icon configured in tauri.conf.json.

How do I build the tray icon and menu in v2?

Everything happens in the `setup` closure, where you have an `&App` and can reach the app handle. The order that matters: create the `MenuItem`s, assemble them into a `Menu`, then pass that menu to `TrayIconBuilder` along with the two event handlers. Build the items with `MenuItem::with_id` so each one carries a stable string id you can match on later — the id is how `on_menu_event` tells a click on `quit` apart from a click on `show`.

src-tauri/src/lib.rs
use tauri::{
    menu::{Menu, MenuItem},
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager,
};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            // 1. Build the menu items, each with a stable id.
            let show = MenuItem::with_id(app, "show", "Show Dev Manager", true, None::<&str>)?;
            let hide = MenuItem::with_id(app, "hide", "Hide", true, None::<&str>)?;
            let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;

            // 2. Assemble them into a menu.
            let menu = Menu::with_items(app, &[&show, &hide, &quit])?;

            // 3. Build the tray, attaching the menu and both handlers.
            TrayIconBuilder::new()
                .icon(app.default_window_icon().unwrap().clone())
                .tooltip("Dev Manager — services running")
                .menu(&menu)
                // Right-click opens the menu; we do NOT want left-click
                // to also open it, so disable show-on-left-click.
                .show_menu_on_left_click(false)
                .on_menu_event(|app, event| match event.id.as_ref() {
                    "show" => {
                        if let Some(win) = app.get_webview_window("main") {
                            let _ = win.show();
                            let _ = win.set_focus();
                        }
                    }
                    "hide" => {
                        if let Some(win) = app.get_webview_window("main") {
                            let _ = win.hide();
                        }
                    }
                    "quit" => app.exit(0),
                    _ => {}
                })
                .on_tray_icon_event(|tray, event| {
                    // Left-click toggles the main window.
                    if let TrayIconEvent::Click {
                        button: MouseButton::Left,
                        button_state: MouseButtonState::Up,
                        ..
                    } = event
                    {
                        let app = tray.app_handle();
                        if let Some(win) = app.get_webview_window("main") {
                            if win.is_visible().unwrap_or(false) {
                                let _ = win.hide();
                            } else {
                                let _ = win.show();
                                let _ = win.set_focus();
                            }
                        }
                    }
                })
                .build(app)?;

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Two details I lost time to. First, `default_window_icon()` returns an `Option`, so the `.unwrap()` assumes you actually configured an icon in `tauri.conf.json`; with no bundle icon set, that line panics on startup. Second, `show_menu_on_left_click(false)` is the line that separates left-click-toggles-window from left-click-also-opens-the-menu — its default is `true`. Leave it at the default and a single left-click both toggles your window and pops the context menu, which feels broken. The `button_state: MouseButtonState::Up` match is deliberate too — fire on release, not press, or the toggle double-triggers on some platforms.

How do I make closing the window minimize to the tray?

This is the behavior people actually want from a tray app: clicking the window's X should hide it, not terminate the process, so the dev manager keeps watching services in the background. You get there by listening for the window's `close-requested` event, calling `api.prevent_close()` to cancel the default close, and hiding the window instead. Register this on the window inside `setup`, after you have the handle.

src-tauri/src/lib.rs (inside setup, after building the tray)
use tauri::WindowEvent;

let window = app.get_webview_window("main").unwrap();
let window_clone = window.clone();
window.on_window_event(move |event| {
    if let WindowEvent::CloseRequested { api, .. } = event {
        // Cancel the default close (which would close the window)...
        api.prevent_close();
        // ...and hide to tray instead.
        let _ = window_clone.hide();
    }
});

Now the X hides the window, the tray icon stays put, and the only paths that genuinely quit are the `quit` menu item calling `app.exit(0)` and the OS killing the process. That is the contract a background tool should have. One platform caveat to plan for upfront.

On macOS, closing the last window does not terminate the app the way it tends to on Windows or Linux — by default the process stays alive in the menu bar, which is exactly the behavior you want from a tray app, but it means your close-to-tray code path behaves differently per OS and has to be tested on each.Md Raihan Hasan
A code editor open on a laptop showing Rust source, representing wiring the Tauri tray handlers in the setup hook
All the tray wiring lives in the setup hook: build the menu, attach it to TrayIconBuilder, and register the menu and tray-icon event handlers before calling .build(app).

What are the platform gotchas worth knowing before you ship?

The tray looks deceptively done once it works on your dev machine, but cross-platform tray behavior is where the rough edges live. These are the ones that cost me debugging time, not theory.

  • macOS shows the tray as a menu-bar item, and a colored icon usually looks wrong there — macOS expects a template (monochrome) icon that adapts to light/dark menu bars. Ship a separate template icon for macOS or it will look like a foreign object.
  • On macOS, left-click on a menu-bar item conventionally opens the menu rather than toggling a window, so users may expect different behavior than on Windows; decide deliberately rather than forcing Windows semantics everywhere.
  • Linux needs an app-indicator backend present (libayatana-appindicator) on the user's machine, or the tray silently does not appear — there is no error, just no icon.
  • Without `tray-icon` in the Cargo features list the whole thing compiles but never shows. If your icon is missing, check the feature flag before you touch any code.
  • default_window_icon() is None when no icon is configured, so the .unwrap() in TrayIconBuilder panics at launch — set a real icon in tauri.conf.json first.

If you are reaching for a tray at all, you are probably building a long-running desktop tool, and Tauri is a good fit for that — I covered the reasoning in why I reach for Tauri instead of Electron. The tray is the visible part of the dev manager I described in building a Laragon-style local dev manager on Windows, which keeps its service state in a small embedded database the way I set up in rusqlite for local session storage. For the exact API surface — `TrayIconBuilder`, the `menu` module, and the event enums — the v2 reference at docs.rs/tauri is the source I trust over any blog post, including this one once a point release moves something.

That is the whole loop: a tray icon with a working menu, left-click toggling the window, and the X hiding to tray instead of quitting, all wired in the v2 `setup` hook. The hard part was never the code volume — it is small — it was knowing that v1 examples are dead and that two separate handlers, one Cargo flag, and a per-OS icon are what stand between a tray that works on your machine and one that ships cleanly on all three platforms.