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"
The
macos-private-apifeature is required for Vibrancy (frosted glass UI).
Thetray-iconfeature is needed forPosition::TrayCenterplacement.
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}]}}
| 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();
}
}
}
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 needfeatures = ["tray-icon"]in Cargo.toml.
Without it, you'll get an error saying theTrayCentervariant 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(())
})
}
Gotcha
⚠️ If you forget to call
tauri_plugin_positioner::on_tray_event(),
Position::TrayCenterwon'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();
}
}
}
_ => {}
})
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(())
})
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) */
}
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(),
)
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![]),
))
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");
}
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:
-
tauri-plugin-positioner for
TrayCenterplacement - window-vibrancy for frosted glass UI
- on_window_event for auto-hide on focus loss
- 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.