Handling Selection and Link Context Menu Clicks

Act on a context menu click in MV3 — what OnClickData contains for selections, links and images, when you need the full selection from the page, and what the click grants you.

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

The click arrives in the service worker as an OnClickData object and a Tab, and most of what you need is already in it — the selected text, the link URL, the image source, the frame. What is not in it is the thing people most often want: the full selection with its formatting, the element that was clicked, or anything about the page’s DOM. Knowing which is which decides whether the handler is three lines or a round trip into the page. This guide is part of context menus and right-click actions.

What the click event carries

Fields in OnClickData by contextThe fields populated for selection, link, image, editable and page clicks, and what each is missing.ContextPopulatedMissingselectionselectionText, pageUrl, frameIdFormatting, full lengthlinklinkUrl, linkText (Firefox), pageUrlThe element itselfimagesrcUrl, mediaType, pageUrlalt text, dimensionseditableeditable: true, frameIdThe field's valuepagepageUrl, frameUrlAnything about the DOM
The selection text is truncated and plain — anything richer needs an injection into the frame that was clicked.

Step-by-step

1. Dispatch by menu id at the top level

 1const HANDLERS = {
 2  "define-selection": defineSelection,
 3  "open-issue": openIssue,
 4  "save-image": saveImage,
 5  "copy-clean-link": copyCleanLink,
 6};
 7
 8chrome.contextMenus.onClicked.addListener((info, tab) => {
 9  return HANDLERS[info.menuItemId]?.(info, tab);
10});

Execution context: the service worker, registered synchronously — the click wakes the worker and is dispatched at the end of the first pass, so a late registration loses it, as set out in registering listeners at the top level. Returning the handler’s promise keeps the worker alive until it settles.

2. Use selectionText when plain text is enough

1async function defineSelection(info) {
2  const term = info.selectionText?.trim().slice(0, 80);
3  if (!term) return;
4  const url = `https://dictionary.example.com/?q=${encodeURIComponent(term)}`;
5  await chrome.tabs.create({ url, index: undefined });
6}

Execution context: the service worker. selectionText is whitespace-normalised and may be truncated for very long selections; for a lookup, both are fine. Always encode it — it is page-controlled text heading into a URL.

3. Fetch the real selection when you need more

For a “save quote” feature that must keep paragraph breaks, links or the full length, read the selection in the frame that was clicked.

 1async function saveQuote(info, tab) {
 2  const [{ result }] = await chrome.scripting.executeScript({
 3    target: { tabId: tab.id, frameIds: [info.frameId ?? 0] },
 4    func: () => {
 5      const sel = getSelection();
 6      if (!sel || sel.isCollapsed) return null;
 7      const range = sel.getRangeAt(0);
 8      const div = document.createElement("div");
 9      div.append(range.cloneContents());
10      return { text: sel.toString(), html: div.innerHTML, url: location.href };
11    },
12  });
13  if (result) await storeQuote(result);
14}

Execution context: the injected function runs in the isolated world of the clicked frame; the surrounding call runs in the service worker. The context-menu click grants activeTab for that tab, so this works without a host permission. Targeting info.frameId matters on pages with iframes — the selection lives in one specific frame, and injecting into the top frame would find nothing. The returned html is page-controlled and must be sanitised before rendering, as in sanitising untrusted page data in an extension.

1async function openIssue(info) {
2  const m = info.linkUrl?.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
3  if (!m) return;                                   // pattern matched loosely, validate precisely
4  const [, owner, repo, num] = m;
5  await chrome.tabs.create({ url: `https://tracker.example.com/gh/${owner}/${repo}/${num}` });
6}

Execution context: the service worker. The menu’s targetUrlPatterns got the item shown only on issue links; the handler still validates exactly, because match patterns cannot express “a number after /issues/”. The filter side is covered in context menu contexts and target filters.

5. Act on images by URL, not by fetching blindly

1async function saveImage(info) {
2  if (!info.srcUrl || info.srcUrl.startsWith("data:") && info.srcUrl.length > 2_000_000) return;
3  const res = await fetch(info.srcUrl, { credentials: "omit" });
4  if (!res.ok || !res.headers.get("content-type")?.startsWith("image/")) return;
5  await putImage({ src: info.srcUrl, page: info.pageUrl, blob: await res.blob() });
6}

Execution context: the service worker. credentials: "omit" avoids sending the user’s cookies for that image host to a URL the page controls. A cross-origin fetch needs a host permission for the image’s origin; without one, fall back to injecting into the page and drawing the image to a canvas there.

6. Know what the click grants

A context-menu click is a user gesture. In Chrome it grants activeTab for the tab, which lasts until the tab navigates — enough for executeScript, captureVisibleTab and reading tab.url.

1chrome.contextMenus.onClicked.addListener(async (info, tab) => {
2  // activeTab is live here, so tab.url is populated even without "tabs"
3  console.debug("[menu]", info.menuItemId, "on", new URL(tab.url).hostname);
4});

Execution context: the service worker. The grant does not extend to permissions.request, which needs a gesture in a visible extension page, and it does not survive a navigation — the lifecycle described in injecting only after a user gesture with activeTab.

A rich selection capture from a nested frameThe user right-clicks selected text inside an iframe; the worker receives the frame id with the click and injects into exactly that frame to read the formatted selection.UserService workerFrame 7 (iframe)IndexedDBclick "Save quote"frameId: 7executeScript({frameIds:[7]})getSelection().getRangeAt(0){ text, html, url }sanitise htmlput(quote)
Without the frame id the injection would target the top document and return nothing.

Feedback after the click

The menu closes the instant the user clicks, and the handler runs in a context they cannot see. Without feedback, a successful save and a silent failure look identical, and users click again.

Three levels of feedback, from lightest to heaviest:

  • A badge tick on the action, cleared after a minute by an alarm. Enough for “saved”.
  • An in-page toast injected into the tab — richer, and appropriate when the action produced something the user will want to see.
  • A notification — reserve for long-running actions that complete after the user has moved on.
 1async function confirmInPage(tabId, message) {
 2  await chrome.scripting.executeScript({
 3    target: { tabId },
 4    func: (msg) => {
 5      const host = document.createElement("div");
 6      host.attachShadow({ mode: "closed" }).innerHTML =
 7        `<div style="position:fixed;bottom:16px;right:16px;padding:8px 12px;border-radius:8px;
 8                     background:#0f172a;color:#fff;font:13px system-ui;z-index:2147483647"></div>`;
 9      host.shadowRoot?.firstElementChild?.append(msg);
10      document.documentElement.append(host);
11      setTimeout(() => host.remove(), 2500);
12    },
13    args: [message],
14  });
15}

Execution context: the injected function runs in the page’s isolated world; the message is passed as an argument and appended as text, never interpolated into markup. The shadow root keeps the page’s CSS away from the toast — the technique from injecting UI with shadow DOM without breaking the page. Note that with a closed root, host.shadowRoot is null; keep a reference to the root returned by attachShadow in real code.

How much feedback a click deservesA decision tree choosing between a badge, an in-page toast and a notification based on how long the action takes and whether the result needs to be seen.How does the action finish?Instantly, nothing to showBadge tickcleared by an alarmNo permission neededaction APIInstantly, with a resultIn-page toastshadow rootactiveTab covers itfrom the clickSeconds or minutes laterNotificationnotifications APIThe user has moved onso tell them where
The lightest feedback that still confirms the action is the right one.

Cross-browser variation

  • Chrome / Edge: OnClickData includes selectionText, linkUrl, srcUrl, pageUrl, frameId, frameUrl and editable. The click grants activeTab.
  • Firefox: browser.menus.onClicked adds linkText, targetElementId (usable with menus.getTargetElement in a content script to get the actual element), and modifiers for Shift/Ctrl-click variants.
  • Safari: supports the core fields; frameId has been less reliable, so a Safari fallback that injects into all frames and picks the one with a non-empty selection is worth having.
  • All three: selectionText is plain text only. Anything that depends on formatting, surrounding context or the DOM needs an injection.

Verification

  1. Right-click a selection inside an iframe and confirm the handler receives a non-zero frameId and the capture returns the text:
1chrome.contextMenus.onClicked.addListener((info) => console.debug(info.menuItemId, info.frameId, info.selectionText));

Execution context: the service worker console. A frameId of 0 for a selection you know is inside an iframe means the click was attributed to the top frame — check the menu’s contexts include selection and not only page.

  1. Save an image from a cross-origin CDN and confirm the fetch omits cookies — check the request in the worker’s Network panel.
  2. Confirm feedback appears for each action and clears.
  3. Right-click on a restricted page and confirm the items are absent rather than failing on click.

FAQ

Can I get the DOM element that was right-clicked?

In Firefox, yes — info.targetElementId with menus.getTargetElement in a content script. In Chrome there is no equivalent; a content script listening for the contextmenu event can record the target just before the menu opens, and the handler can ask for it.

Why is selectionText shorter than what I selected?

The browser truncates it. Inject into the frame and read getSelection().toString() for the full text.

Does the click work if my worker was asleep?

Yes — the click is an event that wakes the worker. It fails only if the listener was registered late.

Other UI/UX Patterns & Interactive Components Resources