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

The chrome.action API — Icon, Title, Badge, Popup

~11 min · chrome.action, manifest, badge, icon, popup

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"In MV3, the toolbar surface is unified: one icon, one badge, one popup, one API. Lesson 1 walks the chrome.action toolbox — small, focused, and yours to repaint per tab whenever something interesting changes."

One Action to Rule Them All

MV2 had two competing concepts:

  • browser_action — always-visible toolbar icon. The default UX for most extensions.
  • page_action — toolbar icon that only appeared when relevant for the current page (think bookmark stars).

MV3 collapsed both into chrome.action. Every extension that wants a toolbar presence declares an "action" object in the manifest, and Chrome shows the icon for every tab by default. To mimic the old page-action behavior (only show on certain pages), you call chrome.action.disable(tabId) / enable(tabId) from the SW at runtime.

The Manifest Declaration

The minimum:

  • action.default_icon — the icon shown in the toolbar. Either a single string path or an object keyed by size.
  • action.default_title — tooltip shown on hover.
  • action.default_popup — HTML file Chrome opens on click. Omit to let your SW handle clicks via chrome.action.onClicked.

Icons should be 16, 32, 48, and 128 pixel variants. Chrome picks the right one based on display density and DevTools settings. Provide them as a multi-key object so Chrome chooses correctly instead of scaling a single image.

The Runtime API

chrome.action exposes a small surface of mutators, all callable from the SW:

  • setIcon({ tabId?, path }) — override the icon globally or per tab.
  • setTitle({ tabId?, title }) — change the tooltip.
  • setBadgeText({ tabId?, text }) — set the little colored badge text overlay. ~4 char max recommended.
  • setBadgeBackgroundColor({ tabId?, color }) — RGB array or hex string.
  • setBadgeTextColor({ tabId?, color }) — Chrome 115+.
  • setPopup({ tabId?, popup }) — change which HTML opens on click.
  • disable(tabId?) / enable(tabId?) — grey out the icon to suggest "not actionable on this page."
  • getUserSettings() — read whether the user has the icon pinned to the toolbar (vs hidden in the puzzle-piece menu).

Notice: every mutator takes an optional tabId. With tabId → that tab only. Without tabId → global default applied to every tab that has not been overridden.

The Click Behavior

Two mutually-exclusive paths for what happens when the user clicks the icon:

  1. Popup mode (default if action.default_popup is set). Click opens the popup; chrome.action.onClicked never fires.
  2. SW-handled mode (no default_popup). Click fires chrome.action.onClicked in the SW; you decide what to do — open a side panel, run a script, send a message.

You can switch between them at runtime via chrome.action.setPopup({ tabId, popup: '' }) (empty popup string disables the popup for that tab and re-enables onClicked).

The Badge as a Signal

Badges are tiny — a max of ~4 characters at most browser scales — so they communicate one piece of information clearly:

  • Count. Unread messages, clips saved today, tasks pending.
  • Status. Two letters indicating mode ("ON" / "OFF", "REC" / "").
  • Alert. A red exclamation when something needs attention.

Empty text (setBadgeText({ text: '' })) clears the badge. ClipDeck will use the badge for today's clip count (Track 5 Lesson 5) and for the per-tab paused state.

chrome.action is the toolbar's full vocabulary: icon, title, badge, popup, click handler. Every mutator accepts an optional tabId for per-tab specialization. Use the badge as a single piece of signal, not a paragraph.
Check if the user pinned the icon. Chrome 91+ chrome.action.getUserSettings() returns { isOnToolbar: boolean }. If false, your icon is hidden behind the puzzle-piece menu — useful to detect on first-install and prompt the user to pin it. Without pinning, users forget you exist.

Code

manifest.json — action block with multi-size icons·json
{
  "manifest_version": 3,
  "name": "ClipDeck",
  "version": "0.8.0",
  "action": {
    "default_title": "ClipDeck — save clips and browse them",
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/16.png",
      "32": "icons/32.png",
      "48": "icons/48.png",
      "128": "icons/128.png"
    }
  },
  "background": { "service_worker": "background.js" },
  "side_panel": { "default_path": "panel.html" },
  "permissions": ["storage", "tabs", "scripting", "activeTab", "sidePanel"],
  "content_scripts": [
    { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }
  ]
}
background.js — badge driven by clip count, refreshes via onChanged·javascript
// background.js — keep the badge in sync with today's clip count
async function refreshBadge() {
  const { clips = [] } = await chrome.storage.local.get("clips");
  const todayStart = new Date();
  todayStart.setHours(0, 0, 0, 0);
  const todayCount = clips.filter((c) => c.savedAt >= todayStart.getTime()).length;

  await chrome.action.setBadgeBackgroundColor({ color: "#1a6bd6" });
  await chrome.action.setBadgeText({
    text: todayCount === 0 ? "" : String(todayCount > 999 ? "999+" : todayCount),
  });
  await chrome.action.setTitle({
    title: todayCount === 0
      ? "ClipDeck — no clips today"
      : `ClipDeck — ${todayCount} clip${todayCount === 1 ? "" : "s"} today`,
  });
}

chrome.runtime.onInstalled.addListener(refreshBadge);
chrome.runtime.onStartup.addListener(refreshBadge);
chrome.storage.onChanged.addListener((c, a) => {
  if (a === "local" && "clips" in c) refreshBadge();
});
background.js — first-install pin-check for onboarding·javascript
// background.js — detect whether the user has pinned the icon (Chrome 91+)
chrome.runtime.onInstalled.addListener(async (details) => {
  if (details.reason !== "install") return;
  try {
    const settings = await chrome.action.getUserSettings();
    if (!settings.isOnToolbar) {
      console.log("[ClipDeck SW] icon is hidden behind the puzzle-piece menu");
      // First-install onboarding could open a 'Welcome' tab here that
      // explains how to pin the icon, or show a one-time notification.
    }
  } catch (err) {
    // getUserSettings is Chrome 91+; older Chrome throws, ignore.
  }
});

External links

Exercise

Bump clipdeck/manifest.json to the first code block (version 0.8.0). Generate or place real 16/32/48/128 px PNG icons under clipdeck/icons/ — for the exercise, you can copy a single file four times if you want; the badge logic doesn't care. Add the second code block (badge refresh) to background.js. Reload. Save a clip via Ctrl+Shift+K — the toolbar icon now shows '1'. Save several more — the badge updates. Switch to tomorrow (or just hardcode todayStart to a far-past date for the test) — badge clears. Hover the icon — tooltip shows the count. Optionally add the third code block and uninstall-then-reinstall ClipDeck to see the console log.
Hint
If the badge does not update when you save a clip, the storage.onChanged listener is probably attached too late — the SW was evicted, woke up for the clip save, but the listener didn't register before the save completed. Put chrome.storage.onChanged.addListener(...) at the top level of background.js, not inside an async function. If the badge shows the right count but the color is the default red, double-check setBadgeBackgroundColor is awaited (it's async). If icons appear pixelated, your single file is being upscaled — provide the four sizes Chrome asks for.

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.