Badge Text, Colour and Count Patterns

Use the toolbar badge well in MV3 — the four-character limit, formatting large counts, per-tab versus global badges, colour and contrast, and clearing stale badges after a worker restart.

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

The toolbar badge is the only piece of an extension that is visible all the time, which makes it the most valuable signal you have and the easiest to abuse. It holds about four characters, it has one background colour, it can be global or per tab, and it persists after the worker that set it has been evicted — so a badge showing “12” may describe a state that stopped being true an hour ago. This guide is part of notifications, badges and the action API.

What a badge can say

A badge is good at three things: a small count (“3 new”), a short state (“ON”, “off”, “!”), and nothing at all. It is bad at anything that needs to be read carefully — the text is a few pixels tall and truncated beyond roughly four characters.

Badge patterns and when each fitsCounts, short states, alert markers and an empty badge compared on what they communicate, how often they should change, and the usual mistake.PatternCommunicatesCommon mistakeCount ("3")Items needing attentionCounting things nobody acts onShort state ("off")Mode on this tabShowing the default stateAlert ("!")Something needs fixingLeaving it after it is fixedEmptyNothing to report
An empty badge is the default — every non-empty state is a claim on the user's attention.

Step-by-step

1. Format counts to fit

1export function badgeCount(n) {
2  if (!n || n < 0) return "";
3  if (n < 1000) return String(n);
4  if (n < 10_000) return `${Math.floor(n / 1000)}k`;
5  return "9k+";
6}
7
8await chrome.action.setBadgeText({ text: badgeCount(unread) });

Execution context: the service worker. Four characters is a practical limit rather than a documented one — longer strings are truncated with no ellipsis on some platforms. Returning an empty string for zero is the important line: “0” is noise, and an empty badge is the clearest way to say “nothing”.

2. Choose global or per-tab deliberately

1// Global: the same badge in every tab (unread across the whole account)
2await chrome.action.setBadgeText({ text: badgeCount(totalUnread) });
3
4// Per tab: a property of the page (requests blocked on this page)
5await chrome.action.setBadgeText({ tabId, text: badgeCount(blockedHere) });

Execution context: the service worker. A per-tab value overrides the global one for that tab only, and is discarded when the tab closes or navigates to a new document in some engines. Mixing the two without a plan produces a badge that flickers between meanings as the user switches tabs.

3. Pick a colour with contrast in both themes

1await chrome.action.setBadgeBackgroundColor({ color: "#b91c1c" });   // alert
2await chrome.action.setBadgeTextColor({ color: "#ffffff" });          // Chrome 110+

Execution context: the service worker. The badge sits on the toolbar, which is light or dark depending on the browser theme — a pale yellow badge disappears on a light toolbar, a navy one on a dark toolbar. Saturated mid-tones such as #b91c1c, #1d4ed8 or #047857 with white text read on both. setBadgeTextColor is newer than the rest of the API; without it, Chrome picks a text colour automatically.

4. Clear the badge when the state it describes ends

Badge state lives in the browser, not the worker. If the worker sets “3” and is evicted, the badge stays “3” until something clears it — including after the unread items were read on another device.

 1chrome.storage.onChanged.addListener(async (changes, area) => {
 2  if (area === "local" && "unread" in changes) {
 3    await chrome.action.setBadgeText({ text: badgeCount(changes.unread.newValue?.length ?? 0) });
 4  }
 5});
 6
 7chrome.runtime.onStartup.addListener(async () => {
 8  const { unread = [] } = await chrome.storage.local.get("unread");
 9  await chrome.action.setBadgeText({ text: badgeCount(unread.length) });
10});

Execution context: the service worker, both listeners at the top level. Deriving the badge from storage — rather than setting it at the moment something happens — means any writer, from any context, keeps the badge correct. The startup handler repairs a badge left stale by a crash.

5. Pair the badge with a tooltip

The badge is glanceable; the title is the explanation. Change them together.

1async function setAttention(tabId, n) {
2  await chrome.action.setBadgeText({ tabId, text: badgeCount(n) });
3  await chrome.action.setTitle({
4    tabId,
5    title: n ? chrome.i18n.getMessage("titleBlocked", [String(n)]) : chrome.i18n.getMessage("titleIdle"),
6  });
7}

Execution context: the service worker. The title is what a screen reader announces for the toolbar button, so a badge with no matching title is invisible to those users — the check in testing extension UI with a screen reader.

A badge derived from storageAny context writes the unread list to storage; the worker's onChanged listener recomputes the badge text and title; startup recomputes it once in case the previous session ended mid-update.Popup marks readstorage.set(unread)Sync job finishesstorage.set(unread)Other device (sync)onChanged arrivesall converge on one listeneronChanged(unread)top-level listenerbadgeCount(n)"" / 3 / 1k / 9k+setBadgeText + setTitlealways together
No code path sets the badge directly from an event — the badge is always a function of stored state.

How much attention a badge should ask for

A badge competes with every other extension’s badge and with the browser’s own indicators. Users adapt quickly: a badge that is always non-zero becomes invisible within days, and a badge that turns red for trivia trains users to ignore red. The most effective extensions use the badge sparingly and reserve colour for the one state that genuinely needs action.

A workable policy has three levels. Most of the time the badge is empty. When there is something the user would plausibly want to act on — new items in a reading list, a paused state on this site — it shows a count or a short word in a neutral colour. Only when something is wrong — sync failing, signed out, a permission revoked — does it turn to an alert colour, and that state must clear itself as soon as the problem does.

Counting only what the user can act on is the other discipline. “Requests blocked on this page” is interesting once; shown on every page it is a number that climbs without meaning. Consider making such counts opt-in, or showing them only in the popup where the user asked to see them.

A day of badge states for a reading-list extensionThe badge is empty most of the day, shows a neutral count after two syncs, briefly turns to an alert when sync fails, and returns to empty after the user reads everything.08:0020:00Emptynothing new"4" neutralafter sync"!" ale…sync fai…"6" neutralsync recoveredEmptyall readtoken expiredre-signed in
The alert colour appears for minutes, not hours — and only while the user can do something about it.

Cross-browser variation

  • Chrome / Edge: setBadgeText, setBadgeBackgroundColor and (Chrome 110+) setBadgeTextColor, each accepting an optional tabId. Per-tab values reset when the tab navigates to a new document.
  • Firefox: the same API under browser.action; also supports windowId scoping, so a badge can differ per window. setBadgeTextColor is supported.
  • Safari: badge text is supported; background colour support has varied, and Safari may render its own style regardless. Do not encode meaning in colour alone on Safari.
  • All three: badge state persists across worker eviction and is not persisted across browser restarts in every engine — recompute on startup rather than assuming either way.

Verification

  1. Set a count and read it back:
1await chrome.action.setBadgeText({ text: "3" });
2await chrome.action.getBadgeText({});
3// "3"

Execution context: the service worker console. With a tabId, getBadgeText({ tabId }) returns the per-tab override or falls back to the global value.

  1. Mark all items read from the popup and confirm the badge clears without reopening anything.
  2. Stop the worker, change the stored unread list from the options page, and confirm the badge still updates — the onChanged listener wakes it.
  3. Switch the browser between light and dark themes and confirm the badge remains legible in both.

FAQ

Can a badge show an icon or emoji?

Emoji render inconsistently across platforms and are often clipped. For a symbol, prefer changing the action icon itself, which is covered in drawing dynamic action icons with OffscreenCanvas.

Should the badge show zero?

No. An empty badge is the clearest way to say “nothing to see”. “0” is visual noise that users learn to ignore — and then they ignore the non-zero values too.

Why is my per-tab badge disappearing?

Per-tab values are cleared when the tab navigates to a new document. Recompute on tabs.onUpdated with status: "complete" if the value should persist across navigations.

Other UI/UX Patterns & Interactive Components Resources