Sep 17, 2025
6 min read
Tauri,
Rust,
macOS,

Complete Guide to Developing macOS Menu Bar Applications with Tauri

This article details how to use the Tauri framework to develop menu bar applications on macOS, including key technical implementations such as creating panel windows, system tray icons, panel positioning, and show/hide controls.

macOS has a type of application called menubar app, which only lives in the menu bar. Examples include system-provided apps like 🛜 wifi, Bluetooth switch, and 🔋 battery display. It is essentially a type of NSPanel on macOS.

I mentioned in a previous article how to use Tauri’s main window to implement floating controls like hover windows. In fact, on macOS, the main window can be completely converted to an NSPanel implementation.

This type of application has the following characteristics:

  1. Floats above other windows, or only shows when needed.
  2. Applications typically do not appear in the Dock.
  3. Clicking the icon opens the display panel.

Of course, more applications combine normal app functionality with a menubar app feature, combining both into one.

Can Tauri create this type of application? The answer is yes.

In fact, Tauri’s underlying dependencies on the macOS platform use Cocoa framework APIs to create windows. Naturally, we can also create menubar apps through Cocoa APIs.

The general process is as follows:

  1. Create a hidden borderless transparent window when the application starts
  2. Create a tray icon in the menu bar
  3. When the user clicks the tray icon, calculate where the panel should appear (directly below the mouse position)
  4. Display the panel without stealing focus (non-activating panel)
  5. Automatically hide the panel when the user clicks elsewhere or switches applications

Disable fullscreen, resize, window decorations, and visibility - normal menubar apps are not displayed.

{
	"fullscreen": false,
	"resizable": false,
	"title": "menubar",
	"width": 350,
	"height": 400,
	"decorations": false,
	"transparent": true,
	"visible": false
  }

Panel

The biggest implementation challenge in Tauri is how to convert Tauri’s default main window into a menu bar panel for the menubar app. This dirty work can be implemented using the following crate:

tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
#[allow(non_upper_case_globals)]
const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7;

/// Convert the specified application window to a menu bar panel
/// 
/// This function converts the main window into a non-activating panel, allowing it to be displayed as a menu bar app
/// without stealing focus from the main application. The panel will be set to exist in all spaces
/// and emit an event notification when it loses focus.
/// 
/// Parameters:
/// - app_handle: Tauri application handle for getting and manipulating the main window
pub fn swizzle_to_menubar_panel(app_handle: &tauri::AppHandle) {
    let panel_delegate = panel_delegate!(SpotlightPanelDelegate {
        window_did_resign_key
    });
	// Get the main window
    let window = app_handle.get_webview_window("main").unwrap();

    let panel = window.to_panel().unwrap();

    let handle = app_handle.clone();

    // Set up panel delegate listener to emit event when panel loses keyboard focus
    panel_delegate.set_listener(Box::new(move |delegate_name: String| {
        if delegate_name.as_str() == "window_did_resign_key" {
            let _ = handle.emit("menubar_panel_did_resign_key", ());
        }
    }));

    // Set panel level above the main menu bar
    panel.set_level(NSMainMenuWindowLevel + 1);

    // Set panel style to non-activating panel
    panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);

    // Set panel collection behavior: can join all spaces, stationary, full screen auxiliary
    panel.set_collection_behaviour(
        NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces
            | NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
            | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
    );

    panel.set_delegate(panel_delegate);
}

Creating System Tray Icons

Tauri itself provides menu bar-related functionality, so it naturally supports system tray icons, but some features need to be enabled:

tauri = { version = "2.8.5", features = [
  "macos-private-api",
  "tray-icon",
  "image-png",
] }
/// Create system tray icon
/// 
/// This function is responsible for creating the application's system tray icon and setting up click event handling logic.
/// When the user clicks the tray icon, it will show or hide the application panel.
/// 
/// # Parameters
/// * `app_handle` - Application handle for accessing various functions and resources of the application
/// 
/// # Return Value
/// Returns a Result type, which contains the created tray icon object on success, and error information on failure
pub fn create(app_handle: &AppHandle) -> tauri::Result<TrayIcon> {
    let icon = Image::from_bytes(include_bytes!("../icons/tray.png"))?;

    TrayIconBuilder::with_id("tray")
        .icon(icon)
        .icon_as_template(true)
        .on_tray_icon_event(|tray, event| {
            let app_handle = tray.app_handle();

            // Handle tray icon click events
            if let TrayIconEvent::Click { button_state, .. } = event {
                // Only handle mouse button up events
                if button_state == MouseButtonState::Up {
                    let panel = app_handle.get_webview_panel("main").unwrap();

                    // If the panel is currently visible, hide it
                    if panel.is_visible() {
                        panel.order_out(None);
                        return;
                    }

                    // Position the panel and display it
                    position_menubar_panel(app_handle, 0.0);

                    panel.show();
                }
            }
        })
        .build(app_handle)
}

When the user clicks on the menu bar, the menu bar panel needs to appear at the specified mouse click position. This requires us to implement positioning of the specified window above the mouse cursor position. We also need to ensure the panel doesn’t exceed screen boundaries, and adjust the position accordingly if the right side of the panel exceeds the screen.

/// Position the menu bar panel at the appropriate location on the screen
/// 
/// This function positions the specified window above the mouse cursor position, mainly for menu bar panel positioning.
/// It ensures the panel doesn't exceed screen boundaries, and adjusts the position accordingly if the right side of the panel exceeds the screen.
/// 
/// # Parameters
/// 
/// * `app_handle` - Tauri application handle for getting window instances
/// * `padding_top` - Top padding value for fine-tuning the panel's vertical position
pub fn position_menubar_panel(app_handle: &tauri::AppHandle, padding_top: f64) {
    let window = app_handle.get_webview_window("main").unwrap();

    // Get monitor information where the mouse cursor is located
    let monitor = monitor::get_monitor_with_cursor().unwrap();

    // Get the monitor's scaling factor
    let scale_factor = monitor.scale_factor();

    // Get the monitor's visible area
    let visible_area = monitor.visible_area();

    // Get monitor position and convert to logical coordinates
    let monitor_pos = visible_area.position().to_logical::<f64>(scale_factor);

    // Get monitor size and convert to logical coordinates
    let monitor_size = visible_area.size().to_logical::<f64>(scale_factor);

    // Get current mouse position
    let mouse_location: NSPoint = unsafe { msg_send![class!(NSEvent), mouseLocation] };

    // Get the window's native handle
    let handle: id = window.ns_window().unwrap() as _;

    // Get the window's current frame information
    let mut win_frame: NSRect = unsafe { msg_send![handle, frame] };

    // Set the window's Y-axis position to be below the top of the screen and height adapted
    win_frame.origin.y = (monitor_pos.y + monitor_size.height) - win_frame.size.height;

    // Adjust Y-axis position according to specified top padding
    win_frame.origin.y -= padding_top;

    // Calculate and set the window's X-axis position to center on the mouse position while avoiding exceeding the right screen boundary
    win_frame.origin.x = {
        // Calculate the panel's right position
        let top_right = mouse_location.x + (win_frame.size.width / 2.0);

        // Check if the panel's right side will exceed the right screen boundary
        let is_offscreen = top_right > monitor_pos.x + monitor_size.width;

        if !is_offscreen {
            // If not exceeding the screen, center the panel on the mouse position
            mouse_location.x - (win_frame.size.width / 2.0)
        } else {
            // If exceeding the screen, calculate the offset and adjust the position
            let diff = top_right - (monitor_pos.x + monitor_size.width);

            mouse_location.x - (win_frame.size.width / 2.0) - diff
        }
    };

    // Apply the new frame position to the window without immediately refreshing the display
    let _: () = unsafe { msg_send![handle, setFrame: win_frame display: NO] };
}

Hide Panel

fn hide_menubar_panel(app_handle: &tauri::AppHandle) {
	if check_menubar_frontmost() {
		return;
	}

	let panel = app_handle.get_webview_panel("main").unwrap();

	panel.order_out(None);
}

Show Panel

pub fn show_menubar_panel(app_handle: tauri::AppHandle) {
    let panel = app_handle.get_webview_panel("main").unwrap();

    panel.show();
}

The above code will turn the main window code into a panel window, for example:

function App() {
  useEffect(() => {
    invoke("init");
  }, []);

  return (
    <div className="container" style={{backgroundColor: "#ab54c8ff"}}>
      <h1>Menubar App</h1>
      <p>Hello World</p>
    </div>
  );
}

The effect is as follows:

[Pasted image 20250922145040.png]

One-stop solution:

If you have similar needs, you can use the author of tauri-nspanel’s ready-to-use project as a startup project scaffold:

git clone https://github.com/ahkohd/tauri-macos-menubar-app-example.git