Implementing Native Menu and System Tray in Electron/Tauri Application
Native menu and system tray are elements that integrate the application into the OS environment. Proper implementation makes the app feel "native" on each platform; improper — looks like a web page in a frame.
Electron: Native Menu
Menu in Electron is built from MenuItem objects and set via Menu.setApplicationMenu.
// main/menu.js
const { Menu, app, shell } = require('electron');
function createAppMenu(mainWindow) {
const isMac = process.platform === 'darwin';
const template = [
// macOS: first item is app name
...(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',
submenu: [
{
label: 'Documentation',
click: () => shell.openExternal('https://docs.your-app.com')
}
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
module.exports = { createAppMenu };
Electron: System Tray
// 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();
}
},
{ 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 };
On macOS, tray icon must be a "Template image" — PNG 16×16 with dark pixels on transparent background. The system automatically inverts color for light/dark theme.
Tauri: Native Menu
// src-tauri/src/lib.rs
use tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem};
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let handle = app.handle();
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)?,
])?;
let menu = Menu::with_items(handle, &[&file_menu, &edit_menu])?;
app.set_menu(menu)?;
Ok(())
})
.on_menu_event(|app, event| {
match event.id().as_ref() {
"new" => { app.emit("menu:new", ()).unwrap(); }
"open" => { app.emit("menu:open", ()).unwrap(); }
_ => {}
}
})
.run(tauri::generate_context!())
.expect("error while running app");
}
Tauri: System Tray
# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
use tauri::tray::{TrayIconBuilder, TrayIconEvent, MouseButton};
fn setup_tray(app: &tauri::App) -> tauri::Result<()> {
let handle = app.handle();
let quit = MenuItem::with_id(handle, "quit", "Quit", true, None::<&str>)?;
let show = MenuItem::with_id(handle, "show", "Show", true, None::<&str>)?;
let menu = Menu::with_items(handle, &[&show, &PredefinedMenuItem::separator(handle)?, &quit])?;
TrayIconBuilder::with_id("main-tray")
.tooltip("My Application")
.menu(&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 {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
}
}
}
})
.build(app)?;
Ok(())
}
Tray behavior differs across OS: on macOS left click usually shows menu, on Windows — shows/hides window. Keep this in mind when designing.







