Build a native application menu in Electron with Menu.buildFromTemplate, built-in roles, the macOS app menu, keyboard accelerators and clicks that reach your page.

Bishal Biswas
Self Learner
The menu bar
Almost every desktop app has a menu bar with File, Edit, View and friends. Users expect it: it is where they look for features, and where they learn keyboard shortcuts.
Where the menu bar lives depends on the system:
- Windows and Linux: inside each window, just below the title bar.
- macOS: at the very top of the screen, shared by the whole app. It changes when the user switches to a different app.
The main process owns the menu. You describe it as a list of plain JavaScript objects, and Electron turns that list into a real, native menu drawn by the operating system.
Think of it like a restaurant menu card. You write down the dishes and their prices (the template). The printer (Electron) prints a proper card in the house style of each restaurant (each operating system). When a guest points at a dish, the kitchen (your click function) cooks it.
The default menu
Run the app from Your First Electron App and look at the menu. You already have one, with standard items such as File, Edit, View and Window. Electron adds this default menu when your app does not set its own.
It is fine for experiments, but it has items your users do not need (like Toggle Developer Tools) and none of your app’s own features. Time to make your own.
Step 1: a first menu from a template
// main.js
const { app, BrowserWindow, Menu } = require('electron');
const path = require('node:path');
const template = [
{
label: 'File',
submenu: [
{ label: 'Say hello', click: () => console.log('Hello from the menu!') },
{ type: 'separator' },
{ role: 'quit' },
],
},
];
app.whenReady().then(() => {
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
createWindow();
});
- The template is an array. Each object in it is a top-level menu.
labelis the text the user sees.submenuis another array: the items inside that menu.clickis a function the main process runs when the item is chosen. Here it prints to your terminal.{ type: 'separator' }draws a thin line between groups of items.{ role: 'quit' }is a ready-made item. More on roles next.Menu.buildFromTemplate(template)turns the array into aMenuobject, andMenu.setApplicationMenu(menu)installs it.
Run npm start, open File and click Say hello. The message appears in the terminal, because click runs in the main process.
Step 2: roles give you native items for free
A role is a built-in behaviour that Electron and the operating system already know how to do. You write { role: 'copy' } and you get a Copy item with the right label, the right shortcut on each system, and the right action, all without a click function.
Some useful roles:
| Role | What it does |
|---|---|
undo, redo, cut, copy, paste, selectAll |
Normal text editing in the focused page |
reload, forceReload |
Reload the page |
toggleDevTools |
Open or close DevTools |
resetZoom, zoomIn, zoomOut |
Page zoom |
togglefullscreen |
Enter or leave full screen |
minimize, close |
Minimise or close the current window |
quit |
Quit the app (labelled “Exit” on Windows) |
about |
Show an About panel |
There are also roles for whole menus: fileMenu, editMenu, viewMenu, windowMenu and, on macOS, appMenu. Each builds a complete standard menu that already matches the platform.
// main.js
const template = [
{ role: 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
{ role: 'windowMenu' },
];
That four-line template rebuilds a sensible default menu. Then you add your own items next to the roles.
Warning: On macOS, keyboard shortcuts for copy, paste, undo and select all only work in your page’s text fields if your menu contains those items (the easiest way is
{ role: 'editMenu' }). Leave the Edit menu out and Cmd + C stops working. Always include it, even if you hide nothing else.
Step 3: the macOS app menu
On macOS, the first menu is special. It is the app menu, titled with your app’s name, and it holds About, Services, Hide, Quit and similar items. Whatever label you give the first menu, macOS shows the app name there instead.
So on macOS you add an extra first menu with role: 'appMenu'. On Windows and Linux there is no such menu, so you leave it out. The usual pattern uses the spread operator (...) to include it only on macOS:
// main.js
const isMac = process.platform === 'darwin';
const template = [
...(isMac ? [{ role: 'appMenu' }] : []),
{
label: 'File',
submenu: [
{ label: 'New Note', accelerator: 'CmdOrCtrl+N', click: () => newNote() },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
{ role: 'editMenu' },
{ role: 'viewMenu' },
{ role: 'windowMenu' },
];
isMac ? [{ role: 'appMenu' }] : []gives a one-item array on macOS and an empty array elsewhere. Spreading an empty array adds nothing.- On macOS, Quit lives in the app menu, so the File menu ends with Close Window instead. On Windows and Linux, File ends with Exit (
quit).
Note: While you develop, macOS shows Electron as the app menu’s title, because your app is really running inside the Electron program. Once you package it, the menu shows your own app name. See Packaging and Making Installers with Forge.
Step 4: keyboard shortcuts with accelerators
An accelerator is a keyboard shortcut attached to a menu item. Electron shows it next to the label and runs the item’s click when the user presses it while your app is focused.
{ label: 'New Note', accelerator: 'CmdOrCtrl+N', click: () => newNote() }
Accelerators are strings of keys joined with +:
CmdOrCtrlmeans Cmd on macOS and Ctrl on Windows and Linux. Use it for almost every shortcut, so each system gets its normal key.- Other modifiers:
Shift,Alt(Option on macOS), andCmdorCtrlif you really want one specific key. - Then one key: a letter, a number,
F1toF24,Enter,Delete,Plusand so on.
Examples: 'CmdOrCtrl+S', 'CmdOrCtrl+Shift+S', 'F11'.
Menu accelerators only work while your app is the active app. For shortcuts that work everywhere, or inside the page only, see Keyboard Shortcuts and Accelerators.
Alt-key mnemonics on Windows and Linux
On Windows and Linux, put & before a letter in a top-level label, like '&File'. The letter is underlined, and Alt + F opens that menu. To show a real &, write &&. macOS ignores this.
Step 5: menu clicks that reach your page
Most menu items need to change something in the page: “New Note” should clear the editor, “Word Wrap” should toggle wrapping. The menu runs in the main process and the editor lives in a renderer, so this is a main-to-renderer message, exactly as in IPC Part 3: Sending Messages to a Window.
First, a helper that sends to whichever window the user is working in:
// main.js
function sendToFocused(channel, ...args) {
const win = BrowserWindow.getFocusedWindow();
if (win) win.webContents.send(channel, ...args);
}
Then menu items that use it, including a checkbox item:
// main.js
{
label: 'File',
submenu: [
{ label: 'New Note', accelerator: 'CmdOrCtrl+N', click: () => sendToFocused('menu:new-note') },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
{
label: 'View',
submenu: [
{
id: 'word-wrap',
label: 'Word Wrap',
type: 'checkbox',
checked: true,
accelerator: 'Alt+Z',
click: (menuItem) => sendToFocused('menu:word-wrap', menuItem.checked),
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
A checkbox item ticks and unticks itself when clicked. By the time click runs, menuItem.checked already holds the new value.
The preload exposes listeners (passing only the data, never the event):
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
onNewNote: (callback) => ipcRenderer.on('menu:new-note', () => callback()),
onWordWrap: (callback) => ipcRenderer.on('menu:word-wrap', (_event, on) => callback(on)),
});
And the page reacts:
// renderer.js
const editor = document.getElementById('editor'); // a <textarea>
window.electronAPI.onNewNote(() => {
editor.value = '';
editor.focus();
});
window.electronAPI.onWordWrap((on) => {
editor.style.whiteSpace = on ? 'pre-wrap' : 'pre';
});
Press Ctrl + N (or Cmd + N): the editor empties. Toggle View, Word Wrap and long lines wrap or scroll.
Changing the menu while the app runs
Give an item an id, and you can find it later and change it:
// main.js
const menu = Menu.getApplicationMenu();
const wrapItem = menu.getMenuItemById('word-wrap');
wrapItem.checked = false; // untick it
wrapItem.enabled = false; // grey it out
You can change properties such as checked, enabled, visible and label on existing items. To add or remove items, build a new menu from an updated template and call Menu.setApplicationMenu() again.
Hiding or removing the menu bar
On Windows and Linux:
Menu.setApplicationMenu(null)removes the menu bar from every window. (Electron’s default menu does not come back.)autoHideMenuBar: truein a window’s options hides the bar until the user presses Alt.win.removeMenu()removes the bar from one window only, which is handy for small dialogs.
On macOS, the app menu bar always exists at the top of the screen. Passing null only removes the default items, which also removes standard shortcuts like Cmd + Q and Cmd + C. Keep at least appMenu and editMenu on macOS.
For menus that pop up when the user right-clicks, see the next lesson, Right-Click Context Menus.
Common mistakes
Copy and paste stop working on macOS
Your custom menu has no Edit menu. Add { role: 'editMenu' }.
Labels with a click that never runs
If an item has a role, Electron uses the role and ignores click. Pick one: a role for built-in behaviour, or a click for your own.
Expecting click to run in the page
click runs in the main process. console.log there prints to the terminal, and document does not exist. Send a message to the page with webContents.send.
Hard-coding Ctrl in accelerators
'Ctrl+S' feels wrong to Mac users, who expect Cmd + S. Use 'CmdOrCtrl+S'.
Quick recap
- Describe the menu as a template array, turn it into a menu with
Menu.buildFromTemplate(), and install it withMenu.setApplicationMenu()after the app is ready. - Items have a
label, an optionalclick,accelerator,type(separator,checkbox,radio),submenuandid. - Roles (
copy,paste,quit,editMenu,viewMenuand more) give you correct native items with no code. - On macOS add
{ role: 'appMenu' }first and always include an Edit menu, or copy and paste shortcuts break. - Use
CmdOrCtrlin accelerators so each system gets its normal modifier key. clickruns in the main process; usewebContents.sendto tell the page.- Find items later with
getMenuItemById()and changechecked,enabled,visibleorlabel.
// main.js: a cross-platform application menu that talks to the page
const { app, BrowserWindow, Menu, shell } = require('electron');
const path = require('node:path');
const isMac = process.platform === 'darwin';
// --- Send a menu command to the window the user is working in
function sendToFocused(channel, ...args) {
const win = BrowserWindow.getFocusedWindow();
if (win) win.webContents.send(channel, ...args);
}
// --- The template: plain objects, roles for built-in behaviour
const template = [
...(isMac ? [{ role: 'appMenu' }] : []), // macOS: About, Hide, Quit...
{
label: '&File', // Alt + F opens it on Windows and Linux
submenu: [
{ label: 'New Note', accelerator: 'CmdOrCtrl+N', click: () => sendToFocused('menu:new-note') },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
{ role: 'editMenu' }, // keeps Cmd/Ctrl + C, V, Z working
{
label: '&View',
submenu: [
{ id: 'word-wrap', label: 'Word Wrap', type: 'checkbox', checked: true, accelerator: 'Alt+Z',
click: (menuItem) => sendToFocused('menu:word-wrap', menuItem.checked) },
{ type: 'separator' },
{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{ role: 'windowMenu' },
{ role: 'help', submenu: [{ label: 'Learn More', click: () => shell.openExternal('https://www.electronjs.org') }] },
];
// --- The window
function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 600,
webPreferences: { preload: path.join(__dirname, 'preload.js') },
});
win.loadFile('index.html');
}
// --- Install the menu once the app is ready
app.whenReady().then(() => {
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (!isMac) app.quit();
});
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready