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.
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.
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.
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.
Cross-browser variation
- Chrome / Edge:
setBadgeText,setBadgeBackgroundColorand (Chrome 110+)setBadgeTextColor, each accepting an optionaltabId. Per-tab values reset when the tab navigates to a new document. - Firefox: the same API under
browser.action; also supportswindowIdscoping, so a badge can differ per window.setBadgeTextColoris 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
- 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.
- Mark all items read from the popup and confirm the badge clears without reopening anything.
- Stop the worker, change the stored unread list from the options page, and confirm the badge still updates — the
onChangedlistener wakes it. - 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.
Related
- Updating the toolbar badge from the service worker — the worker-side mechanics.
- Enabling and disabling the toolbar action per tab — the other per-tab signal.
- Notification permissions and alert fatigue — when a badge is not enough.
- Notifications, badges and the action API — the parent guide.