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.

Published September 18, 2026 Updated September 18, 2026 7 min read
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

Rendering an icon in the service workerThe worker creates an OffscreenCanvas per required size, draws the icon, extracts ImageData, and passes the size-keyed map to action.setIcon, optionally per tab.State changesprogress 40%OffscreenCanvas 16 & 32one per sizeDraw base + overlay2D contextextract pixelsgetImageData()per size{16: …, 32: …}size-keyed mapaction.setIcon({imageData, tabId?})toolbar updates
No offscreen document is needed — OffscreenCanvas is available in the worker itself.

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.

Static paths against runtime ImageDatasetIcon with packaged image paths compared with setIcon with ImageData drawn on an OffscreenCanvas, on cost, flexibility, sharpness and memory.PropertysetIcon({ path })setIcon({ imageData })Cost per updateDecode a fileDraw + extract pixelsCan show a value (e.g. 37%)One file per valueYesSharp on 2× displaysIf you ship both sizesIf you draw both sizesDesigned in a toolYesCode onlyNeeds a DOMNoNo — OffscreenCanvas
Use packaged icons for a handful of fixed states; draw at runtime only for continuous values.

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.

setIcon calls during a two-minute downloadNumber of setIcon calls made while rendering a progress ring for a two-minute download, unthrottled, quantised to twelve steps, and quantised with cached frames.Every progress event480 callsQuantised to 12 steps12 callsQuantised + cached frames12 callsand no redraw cost after the first run
Quantising to what 16 pixels can actually show removes almost every redraw.

Cross-browser variation

  • Chrome / Edge: action.setIcon accepts imageData as a single ImageData or a size-keyed map. OffscreenCanvas with a 2D context is available in the service worker.
  • Firefox: supports imageData in browser.action.setIcon. Firefox also supports theme_icons in the manifest’s action, which declares separate light and dark icons the browser switches between automatically — the cleanest answer to toolbar themes where it exists.
  • Safari: OffscreenCanvas in 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: ImageData passed to setIcon is copied, so reusing the same object across calls is safe.

Verification

  1. 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.

  1. Run a long download and count setIcon calls with a temporary counter; it should match the number of quantised steps.
  2. Switch the browser to a dark theme, open the popup once so it reports the scheme, and confirm the icon colour changes.
  3. 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.

Other UI/UX Patterns & Interactive Components Resources