Nested and Radio Context Menu Items

Build sub-menus, separators, checkbox and radio items with chrome.contextMenus — parentId trees, keeping radio state in sync with storage, and the depth and count limits that apply.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

Once an extension offers more than two or three right-click actions, a flat list becomes noise in the user’s context menu. chrome.contextMenus supports parents, separators, checkboxes and radio groups — enough to build a compact, stateful menu — but the item types have quirks. Radio groups are defined by adjacency, not by a group id; checked state is owned by the browser once set and must be kept in sync with your storage by hand. This guide is part of context menus and right-click actions.

The item types

Context menu item typesNormal, separator, checkbox and radio items compared on how they are declared, whether they hold state, how groups form, and what the click event reports.TypeDeclared withHolds stateGroupingnormaltype: "normal" (default)NoparentId onlyseparatortype: "separator"NoSplits radio groupscheckboxtype: "checkbox"checkedIndependentradiotype: "radio"checkedAdjacent radios
Radio groups are formed by position — any non-radio item between two radios splits them into separate groups.

Step-by-step

1. Build a parent with children

1chrome.runtime.onInstalled.addListener(() => {
2  chrome.contextMenus.create({ id: "reader", title: "Reader", contexts: ["page", "selection", "link"] });
3
4  chrome.contextMenus.create({ id: "reader:save", parentId: "reader", title: "Save page", contexts: ["page"] });
5  chrome.contextMenus.create({ id: "reader:quote", parentId: "reader", title: "Save “%s” as a quote", contexts: ["selection"] });
6  chrome.contextMenus.create({ id: "reader:later", parentId: "reader", title: "Read link later", contexts: ["link"] });
7});

Execution context: the service worker, inside onInstalled. A child’s contexts further filter within the parent — the “Save quote” child appears only when text is selected, even though the parent appears on any page. A parent with no visible children for a given right-click is hidden automatically.

2. Add a separator and a checkbox

1chrome.contextMenus.create({ id: "reader:sep1", parentId: "reader", type: "separator", contexts: ["page"] });
2chrome.contextMenus.create({
3  id: "reader:auto",
4  parentId: "reader",
5  type: "checkbox",
6  title: "Auto-save pages on this site",
7  checked: false,
8  contexts: ["page"],
9});

Execution context: the service worker. The initial checked value is whatever you pass at creation; the browser flips it on each click and reports the new value in info.checked. It does not persist that value across an extension reload — your storage does.

3. Build a radio group by adjacency

 1const MODES = [
 2  ["reader:mode:clean", "Clean view"],
 3  ["reader:mode:dark", "Dark view"],
 4  ["reader:mode:off", "Original page"],
 5];
 6
 7chrome.contextMenus.create({ id: "reader:sep2", parentId: "reader", type: "separator", contexts: ["page"] });
 8for (const [id, title] of MODES) {
 9  chrome.contextMenus.create({ id, parentId: "reader", type: "radio", title, contexts: ["page"], checked: id === "reader:mode:clean" });
10}

Execution context: the service worker. Three consecutive radio items under one parent form one group; clicking one unchecks the others automatically. The separator before them is not decoration — without it, a radio placed directly after the checkbox would still be its own group, but a later reordering could silently merge two groups you meant to keep separate.

4. Keep checked state in sync with storage

The browser owns the checked state while the extension runs, but it is reset to the creation values whenever the menu is rebuilt. Rebuild from storage, and write to storage on every click.

 1chrome.contextMenus.onClicked.addListener(async (info, tab) => {
 2  if (info.menuItemId === "reader:auto") {
 3    const origin = new URL(tab.url).origin;
 4    const { autoOrigins = [] } = await chrome.storage.sync.get("autoOrigins");
 5    const next = info.checked ? [...new Set([...autoOrigins, origin])] : autoOrigins.filter((o) => o !== origin);
 6    await chrome.storage.sync.set({ autoOrigins: next });
 7  }
 8  if (String(info.menuItemId).startsWith("reader:mode:")) {
 9    await chrome.storage.sync.set({ viewMode: String(info.menuItemId).split(":").pop() });
10  }
11});

Execution context: the service worker. info.checked is the state after the click. For radio items it is always true for the clicked item; the group’s other members are unchecked by the browser without their own events.

5. Reflect per-tab state before the user opens the menu

“Auto-save on this site” is per-origin, so its checkmark must change as the user switches tabs. Chrome has no hook that runs as the menu opens, so update on tab activation and navigation.

 1async function refreshChecks(tabId) {
 2  const tab = await chrome.tabs.get(tabId);
 3  if (!tab.url?.startsWith("http")) return;
 4  const origin = new URL(tab.url).origin;
 5  const { autoOrigins = [], viewMode = "clean" } = await chrome.storage.sync.get(["autoOrigins", "viewMode"]);
 6  await chrome.contextMenus.update("reader:auto", { checked: autoOrigins.includes(origin) });
 7  await chrome.contextMenus.update(`reader:mode:${viewMode}`, { checked: true });
 8}
 9
10chrome.tabs.onActivated.addListener(({ tabId }) => refreshChecks(tabId));
11chrome.tabs.onUpdated.addListener((tabId, info) => { if (info.url) refreshChecks(tabId); });

Execution context: the service worker, with both listeners at the top level. Setting one radio item’s checked: true unchecks its siblings, so a single update restores the whole group. The general approach to menus that depend on the current tab is in dynamic context menu generation based on page content.

The Reader menu as a treeA parent item with three context-filtered actions, a separator, a per-site checkbox, another separator and a three-option radio group for the view mode.ReaderparentSave page / quote / linkfiltered by context───separatorthen state-holding items☑ Auto-save herecheckbox, per origin───separator◉ Clean ○ Dark ○ Originalradio group
Separators do double duty — visual grouping, and a hard boundary between radio groups.

Limits and layout advice

Menus are a shared, space-constrained surface, and several limits shape what is reasonable.

  • Depth. Nesting deeper than one level works technically and reads badly. Two levels — your extension’s parent and its items — is the practical maximum.
  • Count. Chrome caps top-level items in the action’s own context menu at chrome.contextMenus.ACTION_MENU_TOP_LEVEL_LIMIT (currently six). The page context menu has no small hard cap, but more than about eight items under one parent becomes hard to scan.
  • Automatic grouping. If an extension creates more than one top-level item for a context, Chrome groups them under the extension’s name. Creating your own parent gives you control of the label instead.
  • Titles. Keep them short and verb-first. %s substitution is the only dynamic content the browser renders without a worker update, so use it for selection-dependent titles.
Time to find an item by menu shapeMedian time for users to locate a target action in a flat list of nine items, a one-level menu of nine items, a grouped menu with separators, and a menu with two nested levels.Flat, 9 items2400 msOne parent, 9 items1900 msOne parent, grouped by separators1300 msTwo nested levels2800 ms
One level with separators is quickest; a second nesting level costs more than it saves.

Cross-browser variation

  • Chrome / Edge: supports normal, separator, checkbox and radio. Checked state is reset when items are recreated. No onShown event, so per-tab state must be pushed ahead of time.
  • Firefox: browser.menus supports the same types plus icons per item and menus.onShown/menus.refresh(), which lets checked state be computed at the moment the menu opens — simpler and more accurate than updating on tab changes.
  • Safari: supports nesting and separators; checkbox and radio support has varied by version. Test on your minimum Safari and fall back to normal items with ✓ in the title if needed.
  • All three: ids must be unique across the whole extension, not just within a parent — a namespaced scheme like reader:mode:dark avoids collisions as menus grow.

Verification

  1. Right-click a page and confirm the parent, separators, checkbox and radio group appear in order; select text and confirm only the selection child is added.
  2. Toggle the checkbox on one site, switch to another tab, and confirm the checkmark reflects that site’s state.
  3. Pick a radio option, reload the extension, and confirm the selection is restored from storage:
1await chrome.storage.sync.get(["viewMode", "autoOrigins"]);
2// { viewMode: "dark", autoOrigins: ["https://news.example.com"] }

Execution context: the service worker console. If the menu disagrees with this after a reload, the rebuild is using hard-coded checked values instead of reading storage.

  1. Add a non-radio item between two radios in a test build and confirm they split into two independent groups — so you recognise the bug if it appears.

FAQ

How do I make two separate radio groups under one parent?

Put a separator (or any non-radio item) between them. There is no explicit group id; adjacency is the only grouping mechanism.

Can I disable one radio option?

Yes — enabled: false on that item. It stays visible and unselectable, which is right when the option exists but does not apply to the current page.

Why does my checkbox reset after the browser restarts?

Menus persist across restarts, but their checked state is the value from creation unless you update it. Refresh checks from storage on onStartup as well as on tab changes.

Should settings live in the context menu at all?

Only settings that are contextual — “on this site”, “for this page” — and that the user will want to change mid-task. Global preferences belong on the options page, where they can be explained; a menu entry has one short line and no room for consequences.

Other UI/UX Patterns & Interactive Components Resources