How to Build a Webpage Annotation Overlay in a Chrome Extension
Drawing over a webpage sounds simple: create a canvas, stretch it across the viewport, and listen for pointer events. That prototype works in a few minutes. Turning it into a usable Chrome extension is a different problem.
The overlay must remain sharp on high-density displays, avoid breaking the page when drawing is disabled, preserve annotations while a side panel closes, handle text and erasing, and capture the visible result without sending private page data to a server.
I faced those problems while building Markerly, a Manifest V3 extension for drawing, highlighting, typing notes, and saving annotated screenshots. This article walks through the architecture and the design choices that mattered most.
Split the extension into three responsibilities
The implementation uses three contexts:
| Context | Responsibility |
|---|---|
| Content script | Creates the overlay, collects pointer input, and renders strokes |
| Side panel | Controls tools, colors, sizes, opacity, capture, and mode |
| Service worker | Stores per-tab state and manages capture sequences |
This separation follows the browser's security model. The content script can interact with the page, but it should not own long-running work. The side panel is user interface, but it may close at any time. The service worker coordinates state that must outlive either UI surface.
Messages form the boundary:
chrome.runtime.sendMessage({
type: "SAVE_STROKES",
tabId,
strokes,
});
Keeping messages small and explicit makes it possible to reason about which component can read or change each piece of data.
Create an overlay without modifying page layout
The canvas should cover the viewport without becoming part of the site's document flow. A fixed-position element is the simplest reliable base:
const canvas = document.createElement("canvas");
Object.assign(canvas.style, {
position: "fixed",
inset: "0",
zIndex: "2147483646",
touchAction: "none",
userSelect: "none",
});
document.documentElement.append(canvas);
Appending to document.documentElement avoids assumptions about how a site's <body> is structured. The high stacking order keeps the drawing surface above most site interfaces.
A high z-index is not enough, however. The overlay can block every link, button, input, and scroll gesture beneath it. That is useful while drawing and unacceptable while reading the page.
Markerly therefore has two explicit modes:

function applyMode(mode) {
canvas.style.pointerEvents = mode === "draw" ? "auto" : "none";
}
In Draw mode, the canvas receives pointer events. In Page mode, events pass through to the website. The overlay can remain visible while the page becomes interactive again.
This is safer than repeatedly removing and recreating the canvas because changing modes does not disturb the current annotation state.
Account for devicePixelRatio
CSS pixels and canvas bitmap pixels are not always the same. On a display with a device pixel ratio of 2, a canvas styled to 1,000 × 700 CSS pixels needs a 2,000 × 1,400 backing bitmap to remain sharp.
function resizeCanvas(canvas, context) {
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(window.innerWidth * ratio);
canvas.height = Math.round(window.innerHeight * ratio);
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
}
After setting the backing size, the drawing context is scaled so the rest of the code can continue using CSS-pixel coordinates. Without this step, strokes look soft and text can become noticeably blurry on modern laptops.
Changing canvas.width or canvas.height clears its bitmap. That is why the annotation model must live outside the canvas itself. After resize, render every stored stroke again.
Store drawing commands, not screenshots
The canvas is a renderer, not the source of truth. Markerly stores a list of strokes:
const stroke = {
kind: "path",
tool: "pen",
color: "#ff4d6d",
size: 32,
opacity: 0.5,
points: [
{ x: 120, y: 80 },
{ x: 127, y: 84 },
{ x: 136, y: 91 },
],
};
Text is another command type with its own position, font size, color, opacity, and value. This model provides several benefits:
- resizing can replay the annotation;
- undo removes one command instead of editing pixels;
- text remains movable after creation;
- per-tab annotations can be serialized to storage;
- erasing can be represented consistently.
Freehand input is captured with Pointer Events rather than separate mouse and touch handlers:
canvas.addEventListener("pointerdown", startDrawing);
canvas.addEventListener("pointermove", continueDrawing);
canvas.addEventListener("pointerup", finishDrawing);
canvas.addEventListener("pointercancel", cancelDrawing);
Calling setPointerCapture(event.pointerId) on pointer down keeps the current gesture connected to the canvas even when the pointer briefly leaves its bounds.
Smooth paths without inventing missing data
Connecting every captured point with a straight segment produces visible corners, especially when pointer events arrive at uneven intervals.
A lightweight improvement is to use the midpoint between consecutive points as the endpoint of a quadratic curve:
context.beginPath();
context.moveTo(points[0].x, points[0].y);
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
const midX = (previous.x + current.x) / 2;
const midY = (previous.y + current.y) / 2;
context.quadraticCurveTo(previous.x, previous.y, midX, midY);
}
context.stroke();
This does not require a complex smoothing library and preserves the user's actual gesture closely enough for highlights and quick annotations.
A one-point stroke needs special handling. Render it as a filled circle; otherwise a click without movement may produce nothing.
Erasing with compositing
An eraser does not need to search for every path that intersects the pointer. Canvas compositing can remove pixels directly:
context.globalCompositeOperation =
stroke.tool === "eraser" ? "destination-out" : "source-over";
destination-out subtracts the new stroke from what has already been drawn. The rendering function must wrap changes in context.save() and context.restore() so eraser settings do not leak into the next pen or text operation.
There is a tradeoff: a pixel eraser makes perfect command-level undo harder because the result depends on render order. For a lightweight annotation tool, replaying the ordered command list is still deterministic and practical. A vector editor would likely need object selection and geometric erasing instead.
Persist annotations per tab
Annotations belong to the page tab where they were created. A single global stroke array would make drawings appear on the wrong website.
The extension keys session storage by tab ID:
function stateKey(tabId) {
return `markerly-state-${tabId}`;
}
await chrome.storage.session.set({
[stateKey(tabId)]: { enabled, mode, strokes },
});
chrome.storage.session is appropriate because the data should survive panel closure but does not need to remain forever. When a tab closes, its stored annotation state can be removed.
Tool preferences such as color, pen size, opacity, and right-click behavior use chrome.storage.local because the user expects those settings to remain across browser sessions.
This distinction prevents temporary page data from becoming permanent history.
Capture locally and explain the permissions
Saving an annotated page requires the visible tab image and the rendered overlay. Chrome provides captureVisibleTab for the visible viewport:
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, {
format: "png",
});
The extension can then combine the page capture and annotation layer locally and trigger a download. No image needs to leave the browser.

Markerly's permission set reflects these operations:
{
"permissions": [
"sidePanel",
"storage",
"tabs",
"scripting",
"downloads"
],
"host_permissions": ["<all_urls>"]
}
<all_urls> is powerful and deserves a precise explanation. The extension needs to insert the annotation layer on ordinary webpages, but it does not transmit page content to a server. Captures are created only after a user action and saved directly to the user's device.
Permission minimization is not only about removing permissions. It is also about making each remaining permission understandable from a visible feature.
Chrome restricts content scripts on internal pages such as chrome:// URLs. The extension should report that limitation instead of failing silently or attempting to bypass the browser's security boundary.
Long-running capture belongs in the service worker
A side panel can close while a timed capture sequence is running. If the interval lives in the panel, closing the panel ends the task.
The capture sequence therefore belongs to the extension service worker. It owns the timer, collects up to a defined number of frames, reports progress through messages, and creates the final ZIP download.
This is a general Manifest V3 lesson: interface lifetime and task lifetime are different. Put a task in a UI document only when it is acceptable for the task to end with that document.
Service workers can also be suspended, so production designs should persist enough progress to recover or use browser APIs whose lifecycle matches the task. For a short, user-visible sequence, keeping the workflow bounded and reporting interruptions is preferable to pretending it will run forever.
A practical checklist
Before shipping a webpage annotation extension, verify these cases:
- drawing remains sharp at multiple device pixel ratios;
- resize redraws existing annotations;
- Page mode restores links, selection, forms, and scrolling;
- pointer cancellation cannot leave the app stuck in drawing mode;
- a single click creates a visible dot;
- text can be placed and moved without starting a pen stroke;
- annotations remain isolated between tabs;
- closing the side panel does not discard session state;
- screenshots are created only after an explicit user action;
- restricted browser pages produce a clear explanation;
- every requested permission maps to a documented feature.
The first canvas overlay is easy. A trustworthy annotation tool comes from handling the boundaries around that canvas: page interaction, browser lifecycles, storage scope, permissions, display density, and privacy.
That is the difference between a drawing demo and an extension people can leave installed.
Comments
Post a Comment