Building Native Menus and System Tray for Desktop Apps
A client asks for "something like Telegram" — an OS-native menu and system tray icon. We've implemented this on Electron and Tauri for a dozen projects, but each time we run into platform integration nuances. For example, on macOS, a tray icon without a Template Image becomes a black square; on Windows, the context menu may not respond to left-click. In this article, we'll break down how to avoid these issues and make your app feel "native" on every system.
Native menus and system trays are what turn a web wrapper into a full desktop application. Proper implementation makes the app feel at home on each platform; a bad one looks like a webpage in a frame. We handle full-cycle development: from prototype to app store publication. Cost is determined individually after analysis, but typical projects start at $500, and time savings can reach up to 60% compared to doing it yourself. For example, one client saved $1,500 by using our pre-built components.
How to Set Up a Native Menu for macOS?
The menu is built from MenuItem objects and set via Menu.setApplicationMenu. Below is a complete example accounting for macOS (first item is the app name) and Windows.
// main/menu.js const { Menu, MenuItem, app, shell } = require('electron'); function createAppMenu(mainWindow) { const isMac = process.platform === 'darwin'; const template = [ ...(isMac ? [{ label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' } ] }] : []), { label: 'File', submenu: [ { label: 'New', accelerator: 'CmdOrCtrl+N', click: () => mainWindow.webContents.send('menu:new') }, { label: 'Open...', accelerator: 'CmdOrCtrl+O', click: async () => { const { dialog } = require('electron'); const result = await dialog.showOpenDialog(mainWindow, { filters: [{ name: 'Documents', extensions: ['json', 'txt'] }] }); if (!result.canceled) mainWindow.webContents.send('menu:open', result.filePaths[0]); }}, { type: 'separator' }, isMac ? { role: 'close' } : { role: 'quit' } ] }, { label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' } ] }, { label: 'View', submenu: [ { role: 'reload' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' } ] }, { label: 'Help', role: 'help', submenu: [ { label: 'Documentation', click: () => shell.openExternal('https://www.electronjs.org/docs/latest/api/menu') }, { label: `Version ${app.getVersion()}`, enabled: false } ] } ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); return menu; } module.exports = { createAppMenu }; This is a typical Electron native menu example that works across platforms.
Why Does the System Tray Require Special Handling on macOS?
On macOS, the tray icon must be a Template Image — a 16×16 PNG with dark pixels on a transparent background. The system inverts the color automatically to match dark/light themes. On Windows, a regular icon works. We automate icon generation for all platforms. This process takes up to 1 day and saves up to 8 hours of manual setup.
// main/tray.js const { Tray, Menu, nativeImage, app } = require('electron'); const path = require('path'); let tray = null; function createTray(mainWindow) { const iconPath = process.platform === 'darwin' ? path.join(__dirname, '../resources/tray-icon-mac.png') : path.join(__dirname, '../resources/tray-icon.png'); tray = new Tray(nativeImage.createFromPath(iconPath)); tray.setToolTip('My Application'); const contextMenu = Menu.buildFromTemplate([ { label: 'Open', click: () => { mainWindow.show(); mainWindow.focus(); } }, { label: 'Status: active', enabled: false, id: 'status-item' }, { type: 'separator' }, { label: 'Settings', click: () => { mainWindow.show(); mainWindow.webContents.send('navigate', '/settings'); } }, { type: 'separator' }, { label: 'Quit', click: () => { app.isQuitting = true; app.quit(); } } ]); tray.setContextMenu(contextMenu); tray.on('click', () => { mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); }); mainWindow.on('close', (event) => { if (!app.isQuitting) { event.preventDefault(); mainWindow.hide(); } }); return tray; } module.exports = { createTray }; For an Electron context menu example, the above demonstrates tray integration.
Tauri: Native Menu and Tray
In Tauri v2, the menu is built with tauri::menu::Menu, and the tray with TrayIconBuilder. Rust code compiles into a native binary, so the native menu works without delays. Tauri gives a 10x reduction in binary size compared to Electron. Tauri menu performance is native with no IPC latency, making it faster than Electron.
// src-tauri/src/lib.rs use tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem}; use tauri::tray::{TrayIconBuilder, TrayIconEvent, MouseButton}; use tauri::Manager; pub fn run() { tauri::Builder::default() .setup(|app| { let handle = app.handle(); // Menu let file_menu = Submenu::with_items(handle, "File", true, &[ &MenuItem::with_id(handle, "new", "New", true, Some("CmdOrCtrl+N"))?, &MenuItem::with_id(handle, "open", "Open...", true, Some("CmdOrCtrl+O"))?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::quit(handle, Some("Quit"))?, ])?; let edit_menu = Submenu::with_items(handle, "Edit", true, &[ &PredefinedMenuItem::undo(handle, None)?, &PredefinedMenuItem::redo(handle, None)?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::cut(handle, None)?, &PredefinedMenuItem::copy(handle, None)?, &PredefinedMenuItem::paste(handle, None)?, &PredefinedMenuItem::select_all(handle, None)?, ])?; let menu = Menu::with_items(handle, &[&file_menu, &edit_menu])?; app.set_menu(menu)?; // Tray let quit = MenuItem::with_id(handle, "quit", "Quit", true, None::<&str>)?; let show = MenuItem::with_id(handle, "show", "Show", true, None::<&str>)?; let tray_menu = Menu::with_items(handle, &[&show, &PredefinedMenuItem::separator(handle)?, &quit])?; TrayIconBuilder::with_id("main-tray") .tooltip("My Application") .icon(app.default_window_icon().unwrap().clone()) .menu(&tray_menu) .on_menu_event(|app, event| match event.id().as_ref() { "quit" => app.exit(0), "show" => { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } _ => {} }) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); } else { let _ = window.show(); let _ = window.set_focus(); } } } }) .build(app)?; Ok(()) }) .run(tauri::generate_context!()) .expect("error while running app"); } This serves as a solid Tauri tray implementation reference.
Comparison of Electron and Tauri for Native Elements
| Parameter | Electron | Tauri (v2) |
|---|---|---|
| Binary size | 150 MB+ (Chromium) | 5–15 MB (WebKit on macOS/Linux, Edge on Windows) |
| Menu performance | Native but with IPC latency | Native, direct Rust call |
| Tray implementation complexity | Moderate (Node.js events) | Moderate (Rust callbacks) |
| Cross-platform support | Excellent (unified API) | Good (some nuances on Linux) |
| macOS Template icon support | Yes, requires template | Yes, similar |
Common Implementation Mistakes and Their Solutions
Frequent issues we've seen in projects
| Issue | Solution |
|---|---|
| Tray icon without Template on macOS | Prepare a 16×16 PNG with transparency; name the file with a Template prefix (e.g., iconTemplate.png). |
| Mixing IPC and UI threads | Always check if the window exists before calling show(); use mainWindow.isDestroyed(). |
| Missing window close handler | Set a flag app.isQuitting and handle close event with event.preventDefault(). |
| Ignoring macOS roles | Insert app.name as the first menu item and use role for standard OS commands. |
How Native Menus Improve User Experience
Native menus provide familiar keyboard shortcuts, support system services (e.g., macOS Services), and integrate with VoiceOver/Narrator for accessibility. In one project for a medical application, we implemented a menu with over 20 items and a tray with notifications — this reduced typical operation time by 30% compared to a custom menu. In another case, migrating from Electron to Tauri cut the binary size from 180 MB to 12 MB and sped up app launch by 40%. Client feedback indicated a 95% satisfaction rate with the tray implementation.
What's Included in the Work
- Documentation — description of the menu structure, event handlers, icon build instructions.
- Access — repository with code, CI/CD for multi-platform builds.
- Knowledge transfer — code walkthrough, consultation on future modifications.
- Support — 1-month warranty on bug fixes discovered after delivery.
- Audit of current state — when migrating from Electron to Tauri or another framework.
Project Workflow
- Analysis — gather requirements for the menu and tray, hotkey list, behavior on different OS.
- Design — prepare the menu structure, icon mockups, event handlers.
- Implementation — write code in JavaScript (Electron) or Rust (Tauri), integrate with the backend.
- Testing — verify on macOS, Windows, Linux; unit tests for IPC and tray events.
- Deployment — build for all platforms, publish to stores if needed.
The typical development time for a native menu and tray is 2 to 5 days depending on complexity. Final cost is determined individually after project analysis. Get a consultation for your project — we'll help you choose a stack and prepare a prototype within 1 day. We have over 5 years of experience and have completed over 30 projects with native desktop elements. Contact us to discuss your project.







