Copying to the Clipboard from a Context Menu

Copy text to the clipboard from an MV3 context menu click: why the service worker can't write it, using an offscreen document with the CLIPBOARD reason, writing from the page via scripting, rich formats and feedback.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The context menu item says “Copy as Markdown link”. The user clicks it, the service worker builds [Title](https://…), calls navigator.clipboard.writeText — and gets “navigator.clipboard is undefined”, because service workers have no Clipboard API. In MV2 the background page could use document.execCommand("copy") on a hidden textarea; MV3 has no background document. There are two working routes: run the copy in the page itself through chrome.scripting under the click’s activeTab grant, or use an offscreen document created with the CLIPBOARD reason. Each has trade-offs around permissions, focus and restricted pages. This guide implements both and picks between them. It belongs to context menus and right-click actions.

Why the copy needs a document

The asynchronous Clipboard API (navigator.clipboard.writeText) is exposed on documents, not on service workers, and in most browsers it requires the document to have focus or recent user activation. The older document.execCommand("copy") needs a document with a selected element. A context menu click is a user gesture, but it is delivered to the service worker, which has neither. So the copy must be performed by a document: the page the user right-clicked (via an injected function, which runs with the gesture’s activeTab grant), or a hidden offscreen document owned by the extension. The page route works on ordinary sites without extra permissions; the offscreen route works even on pages where injection is impossible, but needs the offscreen permission and a correct reason.

Which copy route to useDecision tree: on ordinary pages, inject a function into the clicked page to write the clipboard; on restricted pages or when the page blocks it, use an offscreen document with the CLIPBOARD reason; for rich HTML, prefer the page route with ClipboardItem.Where did the user right-click?ordinary pageInject into the pageactiveTab, writeTextNo extra permissionfocus is already thererestricted pageOffscreen documentreason: CLIPBOARDexecCommand copyin the hidden docrich formatPage + ClipboardItemtext/html + text/plainFallback: plain textoffscreen
Try the page first; fall back to the offscreen document.

Step-by-step: reliable copy from a menu click

1. Create the menu item

1// sw.js
2chrome.runtime.onInstalled.addListener(() => {
3  chrome.contextMenus.create({ id: "copy-md-link", title: "Copy as Markdown link", contexts: ["page", "link", "selection"] });
4});

Execution context: the service worker. Offering the item on pages, links and selections lets one command adapt: the link’s URL and text when right-clicking a link, the page’s title and URL otherwise.

2. Build the text from the click info

1function markdownFor(info, tab) {
2  const url = info.linkUrl ?? info.pageUrl ?? tab?.url ?? "";
3  const text = (info.selectionText || info.linkText || tab?.title || url).replace(/[\[\]]/g, "\\$&").trim();
4  return `[${text}](${url.replace(/\)/g, "%29")})`;
5}

Execution context: the service worker. info carries everything needed: linkUrl and (in recent Chrome) linkText for links, selectionText for selections, pageUrl otherwise. Escape characters that would break Markdown syntax. No page access is needed to build the string.

Copy via the page, with offscreen fallbackThe menu click reaches the worker, which builds the Markdown string and injects a function into the clicked tab to write it; if injection fails on a restricted page, it creates an offscreen document with the CLIPBOARD reason and copies there.Service workerTabOffscreen doconClicked → build markdownexecuteScript(writeText, [md])ok — or error on restricted pagefallback: createDocument(CLIPBOARD)execCommand('co…
The page route covers most clicks; the offscreen route covers the rest.

3. Write from the page under activeTab

 1chrome.contextMenus.onClicked.addListener(async (info, tab) => {
 2  if (info.menuItemId !== "copy-md-link") return;
 3  const text = markdownFor(info, tab);
 4  try {
 5    const [{ result }] = await chrome.scripting.executeScript({
 6      target: { tabId: tab.id, frameIds: [info.frameId ?? 0] },
 7      func: async (t) => { try { await navigator.clipboard.writeText(t); return true; } catch { return false; } },
 8      args: [text],
 9    });
10    if (result) return confirmCopied(tab.id);
11  } catch { /* restricted page or no access */ }
12  await copyViaOffscreen(text);
13  confirmCopied(tab?.id);
14});

Execution context: the service worker injecting into the clicked frame, which has focus because the user just right-clicked in it. The context menu click grants activeTab, so no host permission is needed (declare activeTab and scripting). Targeting info.frameId writes from the frame the user clicked, which matters when the click was inside an iframe. Some pages restrict clipboard access through a Permissions Policy; the injected function reports failure and the fallback takes over.

4. Copy in an offscreen document as the fallback

 1// sw.js
 2async function copyViaOffscreen(text) {
 3  const docs = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
 4  if (!docs.length) {
 5    await chrome.offscreen.createDocument({ url: "offscreen.html", reasons: ["CLIPBOARD"], justification: "Copy text chosen from the context menu" });
 6  }
 7  await chrome.runtime.sendMessage({ target: "offscreen", type: "copy", text });
 8}
 9
10// offscreen.js
11chrome.runtime.onMessage.addListener((m) => {
12  if (m.target !== "offscreen" || m.type !== "copy") return;
13  const ta = document.createElement("textarea");
14  ta.value = m.text;
15  document.body.append(ta);
16  ta.select();
17  document.execCommand("copy");
18  ta.remove();
19});

Execution context: the service worker and an offscreen document. Offscreen documents cannot be focused, so navigator.clipboard.writeText usually fails there; execCommand("copy") on a selected textarea works with the CLIPBOARD reason. Declare offscreen (and clipboardWrite for broad compatibility) in permissions. This route works on browser pages and other places injection cannot reach. See reading and writing the clipboard in MV3.

Copy routes comparedInjecting into the page, an offscreen document, and the popup compared on permissions needed, restricted page support, rich formats and focus requirements.RoutePermissionsRestricted pagesRich HTMLInject into pageactiveTab, scriptingNoClipboardItemOffscreen documentoffscreen (+clipboardWrit…YesPlain textPopup pageNone extraYesYes
Inject into the page by default; keep offscreen as the safety net.

5. Copy rich formats when it helps

1func: async (html, plain) => {
2  const item = new ClipboardItem({
3    "text/html": new Blob([html], { type: "text/html" }),
4    "text/plain": new Blob([plain], { type: "text/plain" }),
5  });
6  await navigator.clipboard.write([item]);
7  return true;
8},
9args: [`<a href="${escapeAttr(url)}">${escapeHtml(title)}</a>`, markdownFor(info, tab)],

Execution context: the injected page function. Writing both HTML and plain text lets rich editors paste a real link while plain-text fields get Markdown. Escape the HTML yourself — page titles are untrusted. ClipboardItem support varies by engine; fall back to writeText when it is missing.

6. Confirm the copy

1async function confirmCopied(tabId) {
2  if (!tabId) return;
3  await chrome.action.setBadgeText({ tabId, text: "✓" });
4  setTimeout(() => chrome.action.setBadgeText({ tabId, text: "" }), 1500);
5}

Execution context: the service worker. A context menu disappears on click, so the user gets no feedback that anything happened. A brief badge tick on the toolbar icon is unobtrusive; the timeout clears it while the worker is still awake from the click.

7. Respect privacy

Clipboard contents are sensitive. Only write in response to an explicit user action, never read the clipboard without a clear feature and permission, and never send copied content anywhere unless the feature says so.

Common mistakes

  • Calling navigator.clipboard in the worker. It does not exist there.
  • writeText in an offscreen document. It usually fails without focus; use execCommand.
  • Injecting into the top frame when the click was in an iframe. Use info.frameId.
  • No fallback for restricted pages. Copy silently fails on browser pages.
  • No feedback. Users try again, doubting it worked.

Cross-browser variation

  • Chrome / Edge: both routes work; offscreen documents need Chrome 109+.
  • Firefox: no offscreen API, but the background event page has a document, so navigator.clipboard.writeText with clipboardWrite permission works directly in the background.
  • Safari: inject into the page under activeTab; Safari is strict about user activation, so write immediately in the injected function.

Verification

  1. Right-click a link on an ordinary page, choose the item, and paste into a text editor: correct Markdown.
  2. Right-click inside an iframe and confirm the frame’s link is copied.
  3. Right-click on chrome://extensions (with the action context) and confirm the offscreen fallback copies.
  4. Paste into a rich editor and confirm the HTML link is used when available.

FAQ

Do I need the clipboardWrite permission?

For the page route under a user gesture, usually not; for the offscreen route, declare it for consistent behaviour across versions.

Can I copy images?

Yes, with ClipboardItem and an image/png Blob, from the page route. Support varies; test per engine.

Can the extension read what the user copied?

Only with clipboardRead, which adds an install warning. Avoid it unless reading is the feature.

Why close the offscreen document after copying?

An idle offscreen document costs memory. Close it with chrome.offscreen.closeDocument() after a short delay when no further copies arrive, and recreate it on the next fallback copy.

What if the page has a strict Content Security Policy?

Functions injected with chrome.scripting.executeScript run in the isolated world and are not blocked by the page’s CSP, so the page route still works. A Permissions Policy that disables clipboard-write is different — it can block the write, which is why the offscreen fallback matters.

Other UI/UX Patterns & Interactive Components Resources