Drawing Dynamic Action Icons with OffscreenCanvas
Render toolbar icons at runtime from an MV3 service worker — OffscreenCanvas and ImageData, the 16/32 pixel sizes, device pixel ratio, theme-aware colours and caching the result.
Table of Contents
A badge can carry four characters; the icon itself can carry a state at a glance — a progress ring, a coloured dot, a mode glyph, a greyed-out variant. MV2 extensions drew these on a <canvas> in the background page. An MV3 service worker has no DOM, but it does have OffscreenCanvas, and chrome.action.setIcon accepts raw ImageData — which together are all you need, with no offscreen document involved. This guide is part of notifications, badges and the action API.
From drawing to the toolbar
Step-by-step
1. Draw at every size the toolbar uses
The toolbar shows the icon at 16 CSS pixels; on a 2× display the browser wants 32 physical pixels. Supply both so the browser never scales.
1function drawIcon(size, { progress = 0, state = "idle" }) {
2 const c = new OffscreenCanvas(size, size);
3 const ctx = c.getContext("2d");
4 const s = size / 16; // draw in a 16-unit coordinate system
5
6 ctx.clearRect(0, 0, size, size);
7 ctx.fillStyle = state === "error" ? "#b91c1c" : "#1d4ed8";
8 ctx.beginPath();
9 ctx.roundRect(1 * s, 1 * s, 14 * s, 14 * s, 3 * s);
10 ctx.fill();
11
12 if (progress > 0) {
13 ctx.strokeStyle = "#ffffff";
14 ctx.lineWidth = 2 * s;
15 ctx.beginPath();
16 ctx.arc(8 * s, 8 * s, 4.5 * s, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI);
17 ctx.stroke();
18 }
19 return ctx.getImageData(0, 0, size, size);
20}
Execution context: the service worker. OffscreenCanvas and its 2D context are available in workers in Chrome, Firefox and Safari 16.4+. Drawing in a 16-unit coordinate space scaled by s keeps the two sizes geometrically identical, which matters when users switch between displays.
2. Set the icon from ImageData
1async function renderIcon(state, tabId) {
2 const imageData = { 16: drawIcon(16, state), 32: drawIcon(32, state) };
3 await chrome.action.setIcon(tabId ? { imageData, tabId } : { imageData });
4}
Execution context: the service worker. Passing a size-keyed object lets the browser pick the right bitmap for the current display. A tabId scopes the icon to one tab — useful for per-page states — and is reset on navigation in some engines, like per-tab badges.
3. Throttle updates for animated states
A progress ring updated on every percent would call setIcon a hundred times during a download. The toolbar does not need that resolution.
1let lastDrawn = -1;
2
3async function onProgress(fraction, tabId) {
4 const step = Math.round(fraction * 12) / 12; // 12 visible steps is plenty at 16px
5 if (step === lastDrawn) return;
6 lastDrawn = step;
7 await renderIcon({ progress: step, state: "busy" }, tabId);
8}
Execution context: the service worker. Quantising the value collapses most calls into no-ops. lastDrawn lives in worker memory and resets on eviction, which only means the next update redraws — harmless.
4. Cache rendered variants
Icons for a fixed set of states — idle, paused, error — are worth rendering once and reusing.
1const cache = new Map();
2
3function iconFor(stateKey, state) {
4 if (!cache.has(stateKey)) {
5 cache.set(stateKey, { 16: drawIcon(16, state), 32: drawIcon(32, state) });
6 }
7 return cache.get(stateKey);
8}
9
10await chrome.action.setIcon({ imageData: iconFor("paused", { state: "paused" }), tabId });
Execution context: the service worker. The cache is lost on eviction and rebuilt on first use, which costs a few milliseconds. For static state icons, shipping PNGs and using setIcon({ path }) is even cheaper — dynamic rendering earns its place for continuous values like progress or counts.
5. Adapt to the toolbar’s theme
A dark-blue icon vanishes on a dark toolbar. Chrome exposes the user’s colour scheme through matchMedia in extension pages but not in the worker, so have a page report it.
1// popup.js or options.js — whenever an extension page is open
2const dark = matchMedia("(prefers-color-scheme: dark)");
3const report = () => chrome.storage.local.set({ toolbarDark: dark.matches });
4dark.addEventListener("change", report);
5report();
1// service worker
2const { toolbarDark = false } = await chrome.storage.local.get("toolbarDark");
3ctx.fillStyle = toolbarDark ? "#60a5fa" : "#1d4ed8"; // lighter blue on dark toolbars
Execution context: the first block runs in an extension page, which has matchMedia; the second in the worker, which reads the stored value. It is an approximation — the page and the toolbar usually share a scheme, but a custom browser theme can differ. Manifest icons can also provide theme_icons on Firefox for a declarative light/dark pair, covered in the variation notes below.
Designing for 16 pixels
Most dynamic icons fail not in code but in design: detail that reads well at 128 pixels becomes mush at 16. A few rules hold up.
One idea per icon state. A 16-pixel icon can show a colour, a single shape, or a very short progress arc. It cannot show a colour and a number and an arc.
Keep a 1-pixel margin. Browsers draw focus rings and hover states around the icon; artwork that touches the edge looks clipped.
Use the badge for text. The badge renders text crisply at a legible size; text drawn into a 16-pixel icon does not. Numbers belong in the badge, as described in badge text, colour and count patterns.
Respect state priority. If the extension can be both “paused” and “syncing”, decide which one the icon shows. Trying to combine them produces an icon that says neither clearly.
Cross-browser variation
- Chrome / Edge:
action.setIconacceptsimageDataas a singleImageDataor a size-keyed map.OffscreenCanvaswith a 2D context is available in the service worker. - Firefox: supports
imageDatainbrowser.action.setIcon. Firefox also supportstheme_iconsin the manifest’saction, which declares separate light and dark icons the browser switches between automatically — the cleanest answer to toolbar themes where it exists. - Safari:
OffscreenCanvasin the background context from Safari 16.4; Safari may render toolbar icons as template (monochrome) images, which discards your colour. Test whether colour carries meaning on Safari and fall back to shape if not. - All three:
ImageDatapassed tosetIconis copied, so reusing the same object across calls is safe.
Verification
- Render each state from the worker console and watch the toolbar:
1await renderIcon({ progress: 0.4, state: "busy" });
2await renderIcon({ state: "error" });
Execution context: the service worker console. Check both a standard and a high-density display if you have one; a blurry icon on the 2× display means the 32-pixel variant is missing.
- Run a long download and count
setIconcalls with a temporary counter; it should match the number of quantised steps. - Switch the browser to a dark theme, open the popup once so it reports the scheme, and confirm the icon colour changes.
- Open two tabs, set a per-tab icon in one, and confirm the other keeps the default.
FAQ
Can I animate the icon?
You can call setIcon on a timer, but in a service worker that means keeping the worker alive — and continuous animation in the toolbar is distracting. Reserve motion for progress, and update it only when the value changes.
Why does my icon look blurry?
The browser is scaling a single size. Always provide at least 16 and 32; add 24 and 48 if you want to be exact on 1.5× and 3× displays.
Do I need an offscreen document for this?
No. OffscreenCanvas is available directly in the worker. Offscreen documents are for DOM APIs the worker genuinely lacks, as covered in creating and closing offscreen documents.
Related
- Badge text, colour and count patterns — where numbers belong.
- Updating the toolbar badge from the service worker — the companion update path.
- Dark mode in extension popups — theme detection in pages.
- Notifications, badges and the action API — the parent guide.