DEV Community

Cover image for Complete Guide to Building a macOS Menu Bar App with Tauri v2
hiyoyo
hiyoyo

Posted on

Complete Guide to Building a macOS Menu Bar App with Tauri v2

Introduction

This guide explains how to build a macOS menu bar utility app (like Bartender, Stats, or iStatMenus) using Tauri v2.

It's based on the actual implementation of HiyokoBar, a menu bar resident HUD monitoring app that I've released.

What You'll Build

  • A window that appears when you click the tray icon in the menu bar
  • Auto-hide when you click outside the window (HUD behavior)
  • Native macOS frosted glass (Vibrancy) UI
  • Toggle visibility with a global shortcut
  • Auto-start at login

1. Project Setup

Required Dependencies

# Cargo.toml
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.1"
window-vibrancy = "0.7.1"
Enter fullscreen mode Exit fullscreen mode

The macos-private-api feature is required for Vibrancy (frosted glass UI).
The tray-icon feature is needed for Position::TrayCenter placement.

tauri.conf.json

There are 5 critical settings for a menu bar app:

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "MyMenuBarApp",
  "version": "1.0.0",
  "identifier": "com.example.menubar",
  "app": {
    "macOSPrivateApi": true,
    "windows": [
      {
        "title": "MyMenuBarApp",
        "width": 360,
        "height": 540,
        "resizable": false,
        "decorations": false,
        "transparent": true,
        "alwaysOnTop": true,
        "visible": false,
        "skipTaskbar": true
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
Setting Value Reason
macOSPrivateApi true Required for Vibrancy
visible false Hide window on startup
decorations false Remove title bar
transparent true Make background transparent
skipTaskbar true Don't show in Dock

2. Core Implementation (lib.rs)

Window Toggle

This is the heart of the menu bar app.

use tauri::Manager;
use tauri_plugin_positioner::{WindowExt, Position};

fn toggle_window(app: &tauri::AppHandle) {
    if let Some(window) = app.get_webview_window("main") {
        if window.is_visible().unwrap_or(false) {
            let _ = window.hide();
        } else {
            // Recover from macOS app-level hide
            #[cfg(target_os = "macos")]
            {
                let _ = app.show();
            }

            let _ = window.unminimize();
            // ★ The magic: Position window directly below the tray icon
            let _ = window.move_window(Position::TrayCenter);
            let _ = window.show();
            let _ = window.set_always_on_top(true);
            let _ = window.set_focus();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Position::TrayCenter is provided by tauri-plugin-positioner and
automatically detects the tray icon position and places the window directly below it.

To use Position::TrayCenter, you need features = ["tray-icon"] in Cargo.toml.
Without it, you'll get an error saying the TrayCenter variant doesn't exist.


3. Building the Tray Icon

Build the tray icon inside setup().

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_positioner::init())
        // ... other plugins ...
        .setup(|app| {
            // Build the tray icon
            let _tray = tauri::tray::TrayIconBuilder::with_id("main_tray")
                .icon(
                    tauri::image::Image::from_bytes(
                        include_bytes!("../icons/tray.png")
                    )
                    .expect("failed to load tray icon"),
                )
                .icon_as_template(true)  // macOS: dark mode support
                .tooltip("MyMenuBarApp")
                .on_tray_icon_event(|tray, event| {
                    // ★ Pass tray events to positioner (required for position calculation)
                    tauri_plugin_positioner::on_tray_event(
                        tray.app_handle(), &event
                    );

                    match event {
                        tauri::tray::TrayIconEvent::Click {
                            button: tauri::tray::MouseButton::Left,
                            button_state: tauri::tray::MouseButtonState::Up,
                            ..
                        } => {
                            toggle_window(tray.app_handle());
                        }
                        _ => {}
                    }
                })
                .show_menu_on_left_click(false)  // Don't show menu on left click
                .build(app)?;

            Ok(())
        })
}
Enter fullscreen mode Exit fullscreen mode

Gotcha

⚠️ If you forget to call tauri_plugin_positioner::on_tray_event(),
Position::TrayCenter won't calculate the correct position and the window will appear at the top-left of the screen.

Tray Icon Specs

  • Setting icon_as_template(true) lets macOS automatically adjust colors for dark/light mode
  • The icon image should be a white + transparent background PNG
  • Recommended size is 22x22 px

4. Auto-Hide on Focus Loss

This is the key to achieving menu bar app behavior.

.on_window_event(|window, event| match event {
    // Don't close with the ✕ button (just hide)
    tauri::WindowEvent::CloseRequested { api, .. } => {
        if window.label() == "main" {
            let _ = window.hide();
            api.prevent_close();
        }
    }

    // ★ Auto-hide when focus is lost
    tauri::WindowEvent::Focused(focused) => {
        if !focused && window.label() == "main" {
            let _ = window.hide();

            // macOS-specific: hide the app itself too
            #[cfg(target_os = "macos")]
            {
                let _ = window.app_handle().hide();
            }
        }
    }

    _ => {}
})
Enter fullscreen mode Exit fullscreen mode

Difference Between window.hide() and app.hide()

This is a macOS-specific trap.

Method Effect
window.hide() Hides only the window
app_handle().hide() Hides the entire app (macOS NSApplication.hide())

With only window.hide(), the app remains visible in macOS Mission Control and the app switcher.
By combining app.hide(), you achieve the completely invisible utility app behavior.

Conversely, when showing the window in toggle_window, you need to call app.show() to recover.


5. macOS Vibrancy (Frosted Glass UI)

.setup(|app| {
    // Apply Vibrancy
    #[cfg(target_os = "macos")]
    if let Some(window) = app.get_webview_window("main") {
        use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
        let _ = apply_vibrancy(
            &window,
            NSVisualEffectMaterial::HudWindow,
            None,
            Some(18.0),  // Corner radius
        );
    }

    // ... tray icon setup ...
    Ok(())
})
Enter fullscreen mode Exit fullscreen mode

Material Options

Material Appearance Recommended Use
HudWindow Dark frosted glass Dark UI HUD apps
Popover Standard popover style Notification panels
Menu Menu style Dropdown-style UIs
Sidebar Sidebar style Settings screens
UnderWindowBackground Subtle background Understated UIs

HudWindow works best for menu bar apps.

CSS Side

To show the Vibrancy effect, the frontend background must be transparent.

body {
  background: transparent;
}

.app-container {
  background: rgba(0, 0, 0, 0.3); /* Semi-transparent to let Vibrancy show through */
  backdrop-filter: blur(0px);      /* CSS blur not needed (OS handles it) */
}
Enter fullscreen mode Exit fullscreen mode

6. Global Shortcut

Allow the window to be summoned via keyboard shortcut even when hidden.

.plugin(
    tauri_plugin_global_shortcut::Builder::new()
        .with_shortcuts(["CmdOrCtrl+Shift+H"])
        .unwrap_or(tauri_plugin_global_shortcut::Builder::new())
        .with_handler(|app, _shortcut, event| {
            if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
                toggle_window(app);
            }
        })
        .build(),
)
Enter fullscreen mode Exit fullscreen mode

The unwrap_or() fallback prevents the entire app from crashing
when the shortcut conflicts with another application.


7. Auto-Start at Login

.plugin(tauri_plugin_autostart::init(
    tauri_plugin_autostart::MacosLauncher::LaunchAgent,
    Some(vec![]),
))
Enter fullscreen mode Exit fullscreen mode

On macOS, using LaunchAgent is recommended.
It also automatically appears in the user's System Settings > Login Items.


8. Complete Code (Full Picture)

pub fn run() {
    tauri::Builder::default()
        // Plugins
        .plugin(tauri_plugin_positioner::init())
        .plugin(tauri_plugin_autostart::init(
            tauri_plugin_autostart::MacosLauncher::LaunchAgent,
            Some(vec![]),
        ))
        .plugin(
            tauri_plugin_global_shortcut::Builder::new()
                .with_shortcuts(["CmdOrCtrl+Shift+H"])
                .unwrap_or(tauri_plugin_global_shortcut::Builder::new())
                .with_handler(|app, _shortcut, event| {
                    if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
                        toggle_window(app);
                    }
                })
                .build(),
        )
        // Commands
        .invoke_handler(tauri::generate_handler![quit_app])
        // Setup
        .setup(|app| {
            // Vibrancy
            #[cfg(target_os = "macos")]
            if let Some(window) = app.get_webview_window("main") {
                use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
                let _ = apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, Some(18.0));
            }

            // Tray Icon
            let _tray = tauri::tray::TrayIconBuilder::with_id("main_tray")
                .icon(tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?)
                .icon_as_template(true)
                .on_tray_icon_event(|tray, event| {
                    tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event);
                    if let tauri::tray::TrayIconEvent::Click {
                        button: tauri::tray::MouseButton::Left,
                        button_state: tauri::tray::MouseButtonState::Up, ..
                    } = event {
                        toggle_window(tray.app_handle());
                    }
                })
                .show_menu_on_left_click(false)
                .build(app)?;

            Ok(())
        })
        // Window events
        .on_window_event(|window, event| match event {
            tauri::WindowEvent::CloseRequested { api, .. } => {
                if window.label() == "main" {
                    let _ = window.hide();
                    api.prevent_close();
                }
            }
            tauri::WindowEvent::Focused(focused) => {
                if !focused && window.label() == "main" {
                    let _ = window.hide();
                    #[cfg(target_os = "macos")]
                    { let _ = window.app_handle().hide(); }
                }
            }
            _ => {}
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls Summary

# Pitfall Solution
1 Vibrancy not working Check if macOSPrivateApi: true is set
2 TrayCenter shows top-left Check if on_tray_event() is being called
3 Can't recover after app.hide() Call app.show() in toggle_window
4 Crash on shortcut registration failure Use unwrap_or() fallback
5 Icon showing in Dock Set skipTaskbar: true
6 Opaque CSS background Set body { background: transparent; }

Conclusion

The minimal setup for a Tauri v2 menu bar app:

  1. tauri-plugin-positioner for TrayCenter placement
  2. window-vibrancy for frosted glass UI
  3. on_window_event for auto-hide on focus loss
  4. TrayIconBuilder for tray icon setup

Compared to Electron, binary size is dramatically smaller (under 10MB)
and memory usage is lower, making Tauri ideal for resident apps.

This article is based on development experience from HiyokoBar.

Top comments (1)

Collapse
 
hiyoyok profile image
hiyoyo

TL;DR: Building a Tauri v2 menu bar app requires 4 key pieces: TrayIconBuilder for the tray icon, tauri-plugin-positioner for TrayCenter placement, window-vibrancy for frosted glass UI, and app_handle().hide() (not just window.hide()) for true auto-hide behavior.