Technology Sep 06, 2026 · 5 min read

Capturing exact DOM elements in a Chrome MV3 extension: DPR, sticky elements, and redaction before pixels exist

Every screenshot extension workflow I've used shares the same flaw: you take a picture of the viewport, then crop. The capture is never of the thing you wanted — it's of a rectangle that happens to contain it. I spent the last months building Snaptura, a Chrome MV3 extension that captures exact DOM...

DE
DEV Community
by Mustafa Bahaa
Capturing exact DOM elements in a Chrome MV3 extension: DPR, sticky elements, and redaction before pixels exist

Every screenshot extension workflow I've used shares the same flaw: you take
a picture of the viewport, then crop. The capture is never of the thing you
wanted — it's of a rectangle that happens to contain it.

I spent the last months building Snaptura,
a Chrome MV3 extension that captures exact DOM elements and turns them into
polished exports. This post walks through the four problems that actually
took engineering — skip the marketing, steal the techniques.

1. Capture at the device's resolution, not the CSS fantasy

t.captureVisibleTab() gives you a bitmap of the viewport. The trap: a
400×300px card on a laptop with devicePixelRatio: 2 is not 400×300 in
that bitmap — it's 800×600, and the browser helpfully encodes the DPR into
the PNG so naive consumers render it double-sized or blurry.

The fix is boring and essential: every capture carries its DPR, and every
crop rectangle is multiplied before slicing:

const dpr = window.devicePixelRatio;
const rect = element.getBoundingClientRect();
// Slice the viewport bitmap in *device* pixels:
const slice = {
  x: Math.round(rect.x * dpr),
  y: Math.round(rect.y * dpr),
  width: Math.round(rect.width * dpr),
  height: Math.round(rect.height * dpr),
};
// …and remember `dpr` so exports stay 1:1 sharp.

Rounding matters. A Math.floor on the wrong edge gives you a 1-device-pixel
seam of the neighboring element — invisible in code, very visible in a
marketing screenshot.

2. Hover-select is a hit-testing problem

"Select the element under the cursor" sounds like document.elementFromPoint
— until you try it on real pages with overlays, shadow DOM, and iframes. What
worked in the end is closer to what DevTools does:

  • Walk up from the hovered node and offer the meaningful ancestors, not just the immediate one (users almost never want the inner <span>; they want the card).
  • Score candidates by visible area and semantic weight — a <button> inside a <div> should be grabbable as either, with a keyboard shortcut to walk up the chain.
  • Paint the selection overlay in a page-level layer that ignores pointer events, so highlighting never changes the page you're capturing.

The heuristic I landed on: the element people mean to select is usually the
nearest ancestor whose background differs from its parent's. Cheap, wrong
occasionally, but predictable in a way users learn in ten seconds.

3. Full-page captures and sticky-element ghosts

Scroll-and-stitch full-page capture has an obvious implementation: scroll by
a viewport height, capture, repeat, then blend the overlap. Two real-world
ruinizers:

Sticky elements stamp themselves over every fold. The fixed header looks
fine in fold one and photobombs folds two through nine. The fix is to detect
position: sticky/fixed elements before scrolling and, per fold, either
hide them or pin their correct-at-this-scroll-offset version. Detection is a
getComputedStyle walk; the subtle part is compositing them back at their
natural position in the final fold, where users expect to see the header.

Scrolling isn't pixel-exact. Page zoom, fractional scroll positions, and
scroll-behavior: smooth all break naive stitching. Forcing
scrollTo({ top: y, behavior: 'instant' }) and waiting for both the scroll
event and a rAF after it eliminated 95% of the seam artifacts.

4. Redact before the pixels exist

Pixel-level redaction (draw a black box over the region) leaves the secret
in the file — metadata strips help, but screenshot tools that blur and undo
are a meme for a reason.

The alternative: redact the DOM itself. Replace the text node with a
placeholder element of identical box metrics, capture, then restore the
original node:

function redact(node) {
  const box = node.getBoundingClientRect();
  const veil = document.createElement('div');
  Object.assign(veil.style, {
    position: 'fixed',
    left: `${box.x}px`, top: `${box.y}px`,
    width: `${box.width}px`, height: `${box.height}px`,
    background: 'currentColor', opacity: '0.85',
  });
  node.hidden = true;
  document.documentElement.appendChild(veil);
  return () => { veil.remove(); node.hidden = false; };
}

Since the sensitive string is never rasterized, it can't leak — not in the
PNG, not in an undo stack, nowhere. The cost is that redaction must be
declared before capture, which turns out to be a fine UX: you mark regions
once, and every future capture of that page respects them.

MV3 logistics worth knowing

  • The service worker dies constantly. Any capture pipeline that assumes a long-lived background page breaks. We keep state in chrome.storage, treat the worker as a router, and do the heavy lifting elsewhere.
  • Offscreen documents are the escape hatch. GIF encoding and video compositing run in an offscreen document with OffscreenCanvas + Workers — keeping the service worker free and alive.
  • tabCapture for viewport recording requires the user gesture and the permission choreography to line up exactly; get that right once and recording "just works."

That's the core of it. If you want to see the end result of these
techniques, the extension is
Snaptura — free, watermark-free, with a
Pro tier and a lifetime option. But the four patterns above stand on their
own for anything you're building with captureVisibleTab.

What did I miss? If you've shipped capture tooling, I'd love to hear which
part hurt most.

DE
Source

This article was originally published by DEV Community and written by Mustafa Bahaa.

Read original article on DEV Community
Back to Discover

Reading List