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.







