Checkbox Context Menu Items That Reflect State

Build checkbox and per-site toggle context menu items in MV3 that always show the right state: storing state in chrome.storage, updating checked on tab switches and navigation, handling clicks, and Firefox's onShown.

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

A context menu item reads “✓ Enable on this site”. The user unchecks it on one site, switches to another tab, right-clicks, and the item is still unchecked — because Chrome’s context menu items have one global checked state, not one per tab, and the extension never updated it when the active tab changed. Or the checkbox flips visually on click but the setting it represents never changes because the click handler read the wrong value. Checkbox items are simple to declare and easy to desynchronise from reality. This guide keeps them honest. It belongs to context menus and right-click actions.

How checkbox items keep state

A context menu item created with type: "checkbox" has a checked property. When the user clicks it, Chrome toggles checked itself and fires contextMenus.onClicked with info.checked (the new state) and info.wasChecked (the old one). The item’s state is global to the extension — the same for every tab and window — and persists for the lifetime of the menu registration. Chrome has no hook that runs just before the menu opens, so if the checkbox should reflect something per-site or per-tab, the extension must update it proactively whenever the relevant context changes: tab activation, navigation, window focus. Firefox does have such a hook — menus.onShown — which makes per-context state much easier there.

Keeping a per-site checkbox correct in ChromeThe user switches to a tab on news.site; tabs.onActivated fires; the worker reads the site's setting and updates the checkbox; the user right-clicks and sees the correct state; clicking toggles it and the worker saves the new value.UserService workerContext menuswitches to news.site tabupdate('site-enabled', {checked: false})right-click → shows uncheckedclick itemonClicked {checked: true}save setting for news.site
Update before the menu opens — Chrome gives you no last-moment hook.

Step-by-step: checkboxes that mirror state

1. Create the item from stored state

 1// sw.js
 2chrome.runtime.onInstalled.addListener(createMenus);
 3chrome.runtime.onStartup.addListener(createMenus);
 4
 5async function createMenus() {
 6  await chrome.contextMenus.removeAll();
 7  const { highlightEnabled = true } = await chrome.storage.sync.get("highlightEnabled");
 8  chrome.contextMenus.create({ id: "highlight-global", type: "checkbox", title: "Highlight prices", checked: highlightEnabled, contexts: ["page", "action"] });
 9  chrome.contextMenus.create({ id: "site-enabled", type: "checkbox", title: "Enable on this site", checked: true, contexts: ["page", "action"] });
10}

Execution context: the service worker. The initial checked value comes from storage, the source of truth, never from a hard-coded default. Recreating menus on install and startup with removeAll first keeps registration idempotent, as in rebuilding context menus after worker restarts.

2. Save the new state on click

1chrome.contextMenus.onClicked.addListener(async (info, tab) => {
2  if (info.menuItemId === "highlight-global") {
3    await chrome.storage.sync.set({ highlightEnabled: info.checked });
4  }
5  if (info.menuItemId === "site-enabled" && tab?.url) {
6    await setSiteOverride(siteKey(tab.url), { enabled: info.checked });
7  }
8});

Execution context: the service worker, with the listener at the top level. info.checked is the state after Chrome toggled it — use it directly rather than reading and inverting the stored value, which races with other updates. Writing to storage lets every context react through storage.onChanged, including content scripts on the affected site, as described in per-site settings and overrides.

Global versus per-site checkbox itemsComparison of a global toggle and a per-site toggle as context menu checkboxes on where state is stored, when the item must be updated, and the extra events required in Chrome.ItemState storedUpdate item whenExtra eventsGlobal togglesettingsSetting changesstorage.onChangedPer-site togglesite:<key>Tab or URL changesonActivated, onUpdated, w…Per-site in Firefoxsite:<key>Menu openingmenus.onShown
Global toggles update only on change; per-site toggles must follow the active tab.

3. Follow the active tab for per-site items

 1async function syncSiteCheckbox(tab) {
 2  if (!tab?.url || !/^https?:/.test(tab.url)) {
 3    return chrome.contextMenus.update("site-enabled", { enabled: false, checked: false });
 4  }
 5  const eff = await effectiveSettings(tab.url);
 6  await chrome.contextMenus.update("site-enabled", { enabled: true, checked: eff.enabled, title: `Enable on ${siteKey(tab.url)}` });
 7}
 8
 9chrome.tabs.onActivated.addListener(async ({ tabId }) => syncSiteCheckbox(await chrome.tabs.get(tabId)));
10chrome.tabs.onUpdated.addListener((tabId, change, tab) => { if (change.url && tab.active) syncSiteCheckbox(tab); });
11chrome.windows.onFocusChanged.addListener(async (windowId) => {
12  if (windowId === chrome.windows.WINDOW_ID_NONE) return;
13  const [tab] = await chrome.tabs.query({ active: true, windowId });
14  syncSiteCheckbox(tab);
15});

Execution context: the service worker, all listeners at the top level. Because the item’s state is global, it must be updated whenever “the site the user would right-click on” changes: switching tabs, navigating in the active tab, and switching windows. Putting the site name in the title (“Enable on news.site”) makes the item self-explanatory. Reading tab.url needs host access to the tab or the tabs permission. On restricted pages the item is disabled rather than lying.

Events that keep a per-site checkbox currentTab activation, URL changes in the active tab, window focus changes and storage changes all funnel into one function that reads effective settings for the active tab and updates the menu item.tabs.onActivatedtab switchtabs.onUpdated (url)navigationwindows.onFocusChangedwindow switchplus storage changes for the sitesyncSiteCheckbox(tab)one functioneffectiveSettings(url)globals + overridecontextMenus.updatechecked + title
Four triggers, one update function.

4. Update items when settings change elsewhere

1chrome.storage.onChanged.addListener(async (changes, area) => {
2  if (area !== "sync") return;
3  if ("highlightEnabled" in changes) chrome.contextMenus.update("highlight-global", { checked: changes.highlightEnabled.newValue });
4  if (Object.keys(changes).some((k) => k.startsWith("site:"))) {
5    const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
6    syncSiteCheckbox(tab);
7  }
8});

Execution context: the service worker. The same setting can be changed from the options page, the popup or another synced device. Updating the menu item on storage changes keeps all controls consistent, so the context menu never contradicts the popup.

5. Use onShown in Firefox for exact state

1if (globalThis.browser?.menus?.onShown) {
2  browser.menus.onShown.addListener(async (info, tab) => {
3    if (!info.menuIds.includes("site-enabled")) return;
4    const eff = await effectiveSettings(tab.url);
5    await browser.menus.update("site-enabled", { checked: eff.enabled, title: `Enable on ${siteKey(tab.url)}` });
6    browser.menus.refresh();
7  });
8}

Execution context: the Firefox background. menus.onShown fires as the menu opens, with the tab it opened on; updating the item and calling refresh() shows the change immediately. This removes the need for the tab-tracking listeners in Firefox, though keeping them does no harm.

6. Prefer radio groups for mutually exclusive choices

For “Theme: Light / Dark / System”, use three type: "radio" items in a group rather than three checkboxes. Chrome manages exclusivity within a group of adjacent radio items. See nested and radio context menu items.

7. Keep checkbox items few and obvious

Context menus are scanned in a fraction of a second. One or two well-labelled toggles under a parent item are useful; a dozen settings in the right-click menu is an options page in the wrong place. Put the most frequent per-site toggle there, and link to options for the rest.

Common mistakes

  • Hard-coded initial checked values. The menu disagrees with storage after restart.
  • Assuming per-tab state. Chrome’s checked state is global; update it on tab changes.
  • Inverting the stored value on click. Use info.checked, which is authoritative.
  • Not updating on external changes. The popup and menu contradict each other.
  • Too many toggles in the menu. Move them to options.

Cross-browser variation

  • Chrome / Edge: global checked state; no pre-show hook; update proactively on tab, window and storage events.
  • Firefox: menus.onShown and menus.refresh() allow updating items as the menu opens; contextMenus is an alias for menus.
  • Safari: supports checkbox items with a reduced set of contexts; per-site toggles follow the same proactive-update approach.

Verification

  1. Disable the extension on site A, switch to site B, right-click: the item should be checked.
  2. Switch back to A: unchecked.
  3. Change the setting from the popup and confirm the menu item updates without reload.
  4. Restart the browser and confirm the global toggle shows the stored state.

FAQ

Can I set checked state per tab in Chrome?

No. Update the single item when the active tab changes.

Does contextMenus.update require the item to be visible?

No. Update it any time; the change applies the next time the menu opens.

Why does the item flip back after clicking?

Your code probably overwrote checked from stale data after the click. Save info.checked and let storage drive further updates.

Do I need the tabs permission to show the site name?

Reading tab.url in onActivated needs either the tabs permission or host access to that site. Extensions with only activeTab can keep a generic title such as “Enable on this site” and still save the per-site value when the item is clicked, because the click itself grants access to the tab.

How do I avoid flicker when switching tabs quickly?

Each update is cheap, but out-of-order async reads can leave the wrong state. Record the tab id that triggered the latest update and ignore results for older ones.

Other UI/UX Patterns & Interactive Components Resources