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.
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.
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.
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.
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
checkedvalues. 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.onShownandmenus.refresh()allow updating items as the menu opens;contextMenusis an alias formenus. - Safari: supports checkbox items with a reduced set of contexts; per-site toggles follow the same proactive-update approach.
Verification
- Disable the extension on site A, switch to site B, right-click: the item should be checked.
- Switch back to A: unchecked.
- Change the setting from the popup and confirm the menu item updates without reload.
- 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.
Related
- Context menu contexts and target filters — where items appear.
- Context menus in Firefox and Safari — onShown and other differences.
- Reacting to option changes in every context — keeping all controls in sync.
- Context menus and right-click actions — the parent topic.