DEV Community

Cover image for Building a Menubar App with Tauri v2 — What Nobody Tells You
hiyoyo
hiyoyo

Posted on

Building a Menubar App with Tauri v2 — What Nobody Tells You

All tests run on an 8-year-old MacBook Air.

A menubar app looks simple from the outside. Click the icon, panel appears, click away, panel hides.

The implementation has more moving parts than you'd expect — especially in Tauri v2, where menubar support changed significantly from v1.


The basic setup

Tauri v2 doesn't have a built-in "menubar mode." You build it by combining a few primitives: a system tray icon, a frameless window, and some positioning logic.

use tauri::{
    Manager, SystemTray, SystemTrayEvent,
    WindowBuilder, WindowUrl,
};

fn main() {
    let tray = SystemTray::new();

    tauri::Builder::default()
        .system_tray(tray)
        .on_system_tray_event(|app, event| {
            if let SystemTrayEvent::LeftClick { position, size, .. } = event {
                let window = app.get_window("main").unwrap();
                position_panel_at_tray(&window, position, size);
                window.show().unwrap();
                window.set_focus().unwrap();
            }
        })
        .run(tauri::generate_context!())
        .unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Positioning the panel under the tray icon

The tray icon position comes from the click event. The panel needs to appear directly below it, centered horizontally.

fn position_panel_at_tray(
    window: &tauri::Window,
    tray_pos: tauri::PhysicalPosition,
    tray_size: tauri::PhysicalSize,
) {
    let panel_size = window.outer_size().unwrap();

    let x = tray_pos.x + (tray_size.width / 2.0) - (panel_size.width as f64 / 2.0);
    let y = tray_pos.y + tray_size.height;

    window.set_position(tauri::PhysicalPosition::new(x, y)).unwrap();
}
Enter fullscreen mode Exit fullscreen mode

On multi-monitor setups this gets complicated — the tray position is in global screen coordinates, and you need to account for which display the tray is on.


Hiding when focus is lost

Click outside the panel → panel disappears. This is the expected behavior that's surprisingly annoying to implement.

// In your React frontend
useEffect(() => {
  const handleBlur = () => {
    appWindow.hide();
  };
  window.addEventListener('blur', handleBlur);
  return () => window.removeEventListener('blur', handleBlur);
}, []);
Enter fullscreen mode Exit fullscreen mode

One catch: if the user clicks a native dialog (like a file picker) launched from your panel, the window loses focus and hides before the dialog appears. You need to suppress blur-hiding during native dialogs.


Keeping it out of the Dock and App Switcher

A menubar app shouldn't appear in the Dock or Cmd+Tab switcher.

// tauri.conf.json
{
  "tauri": {
    "macOS": {
      "activationPolicy": "accessory"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

accessory policy: no Dock icon, no App Switcher entry, stays in menubar only.


Launch at login

Users expect menubar apps to start automatically. Tauri doesn't handle this — you register a LaunchAgent manually:

use std::fs;

pub fn enable_launch_at_login(app_path: &str) {
    let plist = format!(r#"



    Label
    com.hiyoko.app
    ProgramArguments

        {}

    RunAtLoad


"#, app_path);

    let plist_path = dirs::home_dir()
        .unwrap()
        .join("Library/LaunchAgents/com.hiyoko.app.plist");

    fs::write(plist_path, plist).unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Hiyoko PDF Vault → https://hiyokoko.gumroad.com/l/HiyokoPDFVault
X → @hiyoyok

Top comments (0)