Native Menus
System tray icons, native application menus, and desktop notifications for Electron and Tauri apps
You are an expert in implementing native OS integration for desktop applications, including application menus, system tray icons, context menus, and desktop notifications. ## Key Points - **Application menu**: The menu bar at the top of the window (or screen on macOS) - **System tray**: Persistent icon in the notification area with a context menu - **Context menus**: Right-click menus within the application - **Notifications**: OS-native notification toasts/banners - Follow platform conventions: macOS has an app-level menu bar; Windows and Linux have per-window menus. Use `process.platform` checks. - Use template images (grayscale with alpha) for macOS tray icons so they adapt to light/dark menu bar. - Provide keyboard accelerators for all common menu actions (`CmdOrCtrl+S`, etc.). - When minimizing to tray, always provide a way to fully quit the app from the tray context menu. - Request notification permission before sending the first notification. Respect the user's choice if denied. - Keep tray context menus short and focused — five to eight items maximum. - **Tray icon garbage collection**: In Electron, store the `Tray` instance in a variable at module scope. If it gets garbage collected, the tray icon disappears silently. - **macOS menu bar requirements**: macOS apps must have an application menu with standard items (About, Quit, Hide). Omitting these feels broken to users. ## Quick Example ```toml # src-tauri/Cargo.toml [dependencies] tauri-plugin-notification = "2" ```
skilldb get desktop-app-skills/Native MenusFull skill: 470 linesNative Menus, System Tray & Notifications — Desktop Apps
You are an expert in implementing native OS integration for desktop applications, including application menus, system tray icons, context menus, and desktop notifications.
Overview
Desktop applications integrate with the operating system through native UI elements that web apps cannot access: application menu bars, system tray (notification area) icons, context menus, and OS-level notifications. These features make apps feel native and provide quick access to functionality without opening the main window.
Key components:
- Application menu: The menu bar at the top of the window (or screen on macOS)
- System tray: Persistent icon in the notification area with a context menu
- Context menus: Right-click menus within the application
- Notifications: OS-native notification toasts/banners
Setup & Configuration
Electron: Required modules
const {
app,
Menu,
MenuItem,
Tray,
Notification,
nativeImage,
BrowserWindow,
} = require('electron');
const path = require('node:path');
Tauri: Required plugins
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-notification = "2"
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Core Patterns
Electron: Application menu
const isMac = process.platform === 'darwin';
const menuTemplate = [
// macOS app menu
...(isMac
? [{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
}]
: []),
// File menu
{
label: 'File',
submenu: [
{
label: 'New File',
accelerator: 'CmdOrCtrl+N',
click: () => createNewFile(),
},
{
label: 'Open...',
accelerator: 'CmdOrCtrl+O',
click: () => openFile(),
},
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: (_menuItem, browserWindow) => {
browserWindow?.webContents.send('menu:save');
},
},
{ type: 'separator' },
{
label: 'Export',
submenu: [
{ label: 'Export as PDF', click: () => exportAsPDF() },
{ label: 'Export as HTML', click: () => exportAsHTML() },
],
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
// Edit menu
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
// View menu
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
// Help menu
{
label: 'Help',
submenu: [
{
label: 'Documentation',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://yourapp.com/docs');
},
},
],
},
];
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);
Electron: Dynamic menu updates
function updateRecentFilesMenu(recentFiles) {
const fileMenu = Menu.getApplicationMenu()?.items.find(
(item) => item.label === 'File'
);
if (!fileMenu) return;
// Rebuild the menu with updated recent files
const template = [...baseMenuTemplate];
const fileSubmenu = template.find((t) => t.label === 'File');
fileSubmenu.submenu.push(
{ type: 'separator' },
{
label: 'Recent Files',
submenu: recentFiles.map((file) => ({
label: path.basename(file),
click: () => openFile(file),
})),
}
);
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
Electron: System tray
let tray = null;
function createTray(mainWindow) {
const iconPath = path.join(__dirname, 'assets', 'tray-icon.png');
// Use 16x16 on Windows/Linux, template image on macOS
const icon = nativeImage.createFromPath(iconPath);
tray = new Tray(icon);
tray.setToolTip('My Application');
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show App',
click: () => {
mainWindow.show();
mainWindow.focus();
},
},
{
label: 'Status',
submenu: [
{
label: 'Online',
type: 'radio',
checked: true,
click: () => setStatus('online'),
},
{
label: 'Away',
type: 'radio',
click: () => setStatus('away'),
},
{
label: 'Do Not Disturb',
type: 'radio',
click: () => setStatus('dnd'),
},
],
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
app.isQuitting = true;
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
// Click to show/hide window
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
});
}
// Minimize to tray instead of closing
function setupMinimizeToTray(mainWindow) {
mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
}
Electron: Context menus
// main.js — handle context menu requests from renderer
const { ipcMain, Menu } = require('electron');
ipcMain.handle('show-context-menu', async (event, menuItems) => {
return new Promise((resolve) => {
const template = menuItems.map((item) => {
if (item.type === 'separator') return { type: 'separator' };
return {
label: item.label,
enabled: item.enabled !== false,
click: () => resolve(item.id),
};
});
const menu = Menu.buildFromTemplate(template);
menu.popup({
window: BrowserWindow.fromWebContents(event.sender),
callback: () => resolve(null), // menu closed without selection
});
});
});
// renderer.js
document.addEventListener('contextmenu', async (e) => {
e.preventDefault();
const target = e.target.closest('[data-context]');
if (!target) return;
const action = await window.api.invoke('show-context-menu', [
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
{ type: 'separator' },
{ id: 'delete', label: 'Delete', enabled: target.dataset.deletable === 'true' },
]);
if (action === 'copy') handleCopy(target);
else if (action === 'paste') handlePaste(target);
else if (action === 'delete') handleDelete(target);
});
Electron: Notifications
// main.js
const { Notification } = require('electron');
function showNotification(title, body, options = {}) {
if (!Notification.isSupported()) return;
const notification = new Notification({
title,
body,
icon: path.join(__dirname, 'assets', 'icon.png'),
silent: options.silent ?? false,
urgency: options.urgency ?? 'normal', // Linux: low, normal, critical
...options,
});
notification.on('click', () => {
// Bring app to front when notification is clicked
const win = BrowserWindow.getAllWindows()[0];
if (win) {
win.show();
win.focus();
}
options.onClick?.();
});
notification.on('action', (_event, index) => {
options.onAction?.(index);
});
notification.show();
return notification;
}
// Notification with actions (macOS)
showNotification('New Message', 'You have a new message from Alice', {
actions: [
{ type: 'button', text: 'Reply' },
{ type: 'button', text: 'Mark Read' },
],
onAction: (index) => {
if (index === 0) openReply();
else markAsRead();
},
});
Tauri: System tray
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager,
};
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let show = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.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") {
window.show().unwrap();
window.set_focus().unwrap();
}
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
window.show().unwrap();
window.set_focus().unwrap();
}
}
})
.build(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Tauri: Notifications
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from '@tauri-apps/plugin-notification';
async function notify(title, body) {
let granted = await isPermissionGranted();
if (!granted) {
const permission = await requestPermission();
granted = permission === 'granted';
}
if (granted) {
sendNotification({ title, body });
}
}
Best Practices
- Follow platform conventions: macOS has an app-level menu bar; Windows and Linux have per-window menus. Use
process.platformchecks. - Use template images (grayscale with alpha) for macOS tray icons so they adapt to light/dark menu bar.
- Provide keyboard accelerators for all common menu actions (
CmdOrCtrl+S, etc.). - When minimizing to tray, always provide a way to fully quit the app from the tray context menu.
- Request notification permission before sending the first notification. Respect the user's choice if denied.
- Keep tray context menus short and focused — five to eight items maximum.
Common Pitfalls
- Tray icon garbage collection: In Electron, store the
Trayinstance in a variable at module scope. If it gets garbage collected, the tray icon disappears silently. - macOS menu bar requirements: macOS apps must have an application menu with standard items (About, Quit, Hide). Omitting these feels broken to users.
- Notification spam: Sending too many notifications causes the OS or user to mute your app. Batch related notifications and respect Do Not Disturb.
- Wrong icon sizes: Tray icons should be 16x16 (or 32x32 @2x) on macOS, 16x16 or 32x32 on Windows. Oversized icons look blurry.
- Platform-specific accelerators:
CmdOrCtrlhandles macOS Command vs. Windows/Linux Control. Don't hardcodeCtrlalone.
Core Philosophy
Native menus, system tray icons, and notifications are how your application participates in the operating system's user experience. These are not optional polish — they are the interfaces users rely on to access your app quickly, receive timely information, and feel that your app belongs on their platform. An app without a proper menu bar on macOS or a tray icon for background operation feels like a ported web page, not a native application.
Follow platform conventions strictly. macOS has a screen-level menu bar with an app name menu containing About, Preferences, and Quit. Windows and Linux have per-window menus. Tray icons should be 16x16 on macOS (as template images for dark/light adaptation) and 16x16 or 32x32 on Windows. Keyboard accelerators should use CmdOrCtrl to handle macOS Command vs. Windows/Linux Control. Deviating from these conventions makes users feel disoriented.
Notifications are a privilege, not a right. Every notification should be actionable, timely, and respectful of the user's attention. Batching related notifications, respecting Do Not Disturb mode, and providing a way to disable notification categories keeps users from muting your app entirely. The goal is to be helpful, not noisy.
Anti-Patterns
-
Omitting the macOS app menu — macOS apps must have an application menu with standard items (About, Preferences, Services, Hide, Quit); skipping it makes the app feel broken to macOS users.
-
Letting the tray icon get garbage collected — in Electron, storing the
Trayinstance in a local variable instead of module scope allows JavaScript garbage collection to destroy it, causing the icon to disappear silently. -
Sending excessive notifications — bombarding users with individual notifications for each event instead of batching them causes the OS or user to mute the app permanently.
-
Using wrong icon sizes — oversized tray icons render as blurry scaled-down images; provide platform-appropriate sizes (16x16 for tray on macOS, 16x16 or 32x32 on Windows) for crisp rendering.
-
Not providing a quit option in the tray menu — when minimizing to tray on window close, omitting a tray context menu "Quit" option traps users who cannot find a way to fully exit the application.
Install this skill directly: skilldb add desktop-app-skills
Related Skills
Auto Update
Auto-update mechanisms for desktop applications using electron-updater and Tauri's built-in updater
Electron Ipc
IPC communication patterns for Electron main and renderer process messaging
Electron
Electron fundamentals for building cross-platform desktop applications with web technologies
File System Access
File system access patterns, native dialogs, and drag-and-drop for desktop applications
Packaging
Building, packaging, and distributing desktop applications for Windows, macOS, and Linux
Security
Desktop application security including context isolation, preload scripts, CSP, and sandboxing for Electron and Tauri