C.W.K.
Stream
Lesson 03 of 05 · published

Context Menus — Right-Click as a First-Class Trigger

~12 min · contextMenus, selection, right-click, background

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"The keyboard shortcut is for power users. The popup is for discovery. The right-click menu is for everyone in between — the user who selected some text and isn't quite sure how to save it. Lesson 3 adds the menu that makes ClipDeck obvious without making it loud."

The API Shape

chrome.contextMenus is small and entirely SW-side. Three calls cover almost everything:

  • create({ id, title, contexts, parentId? }) — add a menu item. Returns the id (useful when you let Chrome auto-generate it).
  • update(id, { title?, enabled?, visible? }) — change an existing item without recreating it.
  • remove(id) — delete an item.
  • removeAll() — wipe everything (useful at install / startup before re-creating).

Plus the click event: chrome.contextMenus.onClicked.addListener((info, tab) => ...). info describes the click (which menu id, current selection text, page URL); tab is the tab where the click happened.

Contexts — Where the Menu Appears

The contexts array decides when the item is in the menu:

  • page — right-click on any page background (most permissive).
  • selection — only when the user right-clicks on highlighted text. Perfect for "Save selection to ClipDeck."
  • link — only on anchor tags.
  • image — only on <img> elements.
  • video, audio — on those media elements.
  • editable — only on input fields / textareas.
  • frame — inside iframes specifically.
  • action — when the user right-clicks on the extension's toolbar icon (Chrome 88+).

An item with contexts: ['selection'] stays hidden until the user actually selects something. No menu noise on pages where ClipDeck cannot do anything useful.

The Install-Time Pattern

Context menus are stored by Chrome but you have to re-create them every install / startup. The idiomatic pattern:

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.removeAll(() => {
    chrome.contextMenus.create({
      id: 'clipdeck-save-selection',
      title: 'Save "%s" to ClipDeck',
      contexts: ['selection'],
    });
  });
});

The %s in the title is replaced by Chrome with the actual selected text (truncated). The user sees "Save 'service workers idle-evict' to ClipDeck" instead of a generic label — instantly readable.

Handling the Click

The onClicked handler runs in the SW. It receives an info object with the relevant fields populated:

  • info.menuItemId — which item was clicked. Always check this first; one listener can handle multiple items.
  • info.selectionText — the selected text (for selection context). Up to ~1024 chars.
  • info.pageUrl — the page where the click happened.
  • info.linkUrl — for link context, the destination URL.
  • info.srcUrl — for image / video / audio, the media URL.
  • info.frameUrl — when the click was inside an iframe.

For ClipDeck's save flow, the SW can build a clip directly from info.selectionText, info.pageUrl, and tab.title — no message to the content script needed, because the context menu already gave you everything.

Nested Menus and Parent IDs

To group items under a submenu, create a parent item first (with no onclick, just a title) and pass its id as parentId on the children:

  • Parent: { id: 'clipdeck-root', title: 'ClipDeck', contexts: ['selection'] }
  • Child A: { id: 'cd-save', parentId: 'clipdeck-root', title: 'Save selection', contexts: ['selection'] }
  • Child B: { id: 'cd-save-tagged', parentId: 'clipdeck-root', title: 'Save with tag…', contexts: ['selection'] }

Submenus help when you have more than 2–3 related actions. With just one, a flat top-level item is clearer.

Context menus complete the discovery story: keyboard for speed, popup for browsing, right-click for in-the-moment intent. The contexts array keeps menus quiet where they aren't useful. %s in the title shows the user what they're about to save.
Chrome groups multi-extension menu items. If three extensions all add a 'Save to X' item, Chrome eventually groups them under a single 'Extensions' submenu. You don't control that grouping; Chrome decides based on item count and width. Keep your menu items short and titled clearly so they read well both top-level and nested.

Code

manifest.json — add contextMenus to permissions·json
{
  "permissions": ["storage", "tabs", "scripting", "activeTab", "sidePanel", "contextMenus"]
}
background.js — selection-context save + action-context panel-open·javascript
// background.js — install the save-selection menu item
function installMenus() {
  chrome.contextMenus.removeAll(() => {
    chrome.contextMenus.create({
      id: "clipdeck-save-selection",
      title: 'Save "%s" to ClipDeck',
      contexts: ["selection"],
    });
    chrome.contextMenus.create({
      id: "clipdeck-open-panel",
      title: "Open ClipDeck side panel",
      contexts: ["action"], // right-click on the toolbar icon
    });
  });
}

chrome.runtime.onInstalled.addListener(installMenus);
chrome.runtime.onStartup.addListener(installMenus);

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId === "clipdeck-save-selection" && info.selectionText) {
    const clip = {
      id: crypto.randomUUID(),
      text: info.selectionText.trim(),
      url: info.pageUrl,
      title: tab?.title ?? "",
      savedAt: Date.now(),
    };
    const { clips = [] } = await chrome.storage.local.get("clips");
    await chrome.storage.local.set({ clips: [clip, ...clips] });
    return;
  }
  if (info.menuItemId === "clipdeck-open-panel" && tab?.id) {
    // Note: opening side panel from a right-click on the toolbar icon
    // counts as a user gesture in Chrome 116+; older Chrome may reject.
    try { await chrome.sidePanel.open({ tabId: tab.id }); } catch {}
  }
});
background.js — submenu pattern for multiple related actions·javascript
// background.js — nested 'ClipDeck' submenu with multiple save flavors
function installNestedMenus() {
  chrome.contextMenus.removeAll(() => {
    chrome.contextMenus.create({
      id: "cd-root",
      title: "ClipDeck",
      contexts: ["selection"],
    });
    chrome.contextMenus.create({
      id: "cd-save",
      parentId: "cd-root",
      title: 'Save "%s"',
      contexts: ["selection"],
    });
    chrome.contextMenus.create({
      id: "cd-save-and-tag",
      parentId: "cd-root",
      title: 'Save "%s" + add tag…',
      contexts: ["selection"],
    });
  });
}

External links

Exercise

Add "contextMenus" to clipdeck/manifest.json's permissions. Add the second code block to clipdeck/background.js. Reload the extension at chrome://extensions — important, because adding a permission requires Chrome to re-prompt. Open a Wikipedia article, select a paragraph, right-click. You should see Save "<your selection>" to ClipDeck in the menu. Click it. Open the side panel — the clip appears with the source title and URL. Try right-clicking the ClipDeck toolbar icon — Open ClipDeck side panel should appear; click it to confirm. Bonus: replace with the third code block to see the nested ClipDeck submenu and confirm both children appear.
Hint
If the menu doesn't appear at all, the permission wasn't recognized — chrome://extensions → ClipDeck → confirm Read and modify your data on all websites style warnings include contextMenus. Sometimes Chrome silently keeps the old permission set until you remove + re-add the extension; if a simple reload doesn't work, try removing and loading the unpacked directory again. If Open ClipDeck side panel from the action context throws on click, your Chrome is older than 116 — older versions don't treat that right-click as a user gesture for sidePanel.open. The save-selection flow doesn't have that constraint.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.