html2canvas doesn't take screenshots. It reads your DOM and computed styles, then repaints its best guess onto a <canvas> — a reimplementation of the browser's renderer, written in JavaScript, maintained by people who are not your browser vendor. getDisplayMedia is the opposite trade: the browser hands you the exact pixels it already rendered, and in exchange you accept a permission prompt and a user gesture. If you're looking for an html2canvas alternative for feedback or bug-report screenshots, the answer is getDisplayMedia — accurate-with-consent beats silent-but-wrong. If you need silent, automated capture, DOM rasterization is still the only game in town — though probably not html2canvas itself anymore. We build a feedback widget for a living and had to make this call. Here's the full map.
html2canvas vs getDisplayMedia at a glance
| html2canvas | getDisplayMedia | |
|---|---|---|
| What you get | A repaint — the library's best guess at your page | The browser's actual rendered pixels |
| Fidelity | Approximate; breaks on modern CSS, cross-origin content, canvas/video | Pixel-perfect, including iframes, video, WebGL |
| Permission prompt | None | One per capture |
| User gesture | Not required | Required (transient activation) |
| HTTPS | Not required | Required (secure context) |
| Mobile browsers | Works | Not supported |
| Bundle cost | ~198 KB minified, ~45 KB gzipped | 0 KB — built into the browser |
| Best for | Silent or automated capture | User-initiated screenshots |
These are two different tools, not two implementations of the same one. The mistake is treating "javascript screenshot of page" as a single problem — what each approach produces, and what it costs, barely overlap.
How html2canvas works — and where it breaks
html2canvas walks your DOM, reads every element's computed style, and manually paints each box, border, gradient, shadow, and glyph onto a canvas. There is no screenshot anywhere in that pipeline. The README says so itself: the image is built "based on the information available on the page" and "may not be 100% accurate."
The architecture has a structural consequence: every CSS property the library supports is hand-written drawing code, and every property it doesn't support renders wrong or not at all. Browsers ship new CSS every quarter; a userland repainter has to chase them forever. In practice, html2canvas problems cluster into five groups:
- Modern CSS. The last release predates container queries,
:has(), CSS nesting, andoklch()colors — the painter has never heard of most CSS shipped since early 2022. Unsupported properties don't error; they silently render wrong. - Cross-origin images and iframes. Canvas tainting rules mean cross-origin images need a proxy server to appear at all, and cross-origin iframes — your embedded checkout, your support widget — can't be read, period.
- Canvas, video, and WebGL. Pixels that were never in the DOM as styled boxes (charts, maps, players) frequently come out blank or stale.
- Web fonts and text metrics. Small differences in line-breaking and glyph measurement shift layout, so text-heavy captures drift from what the user saw.
- Shadow DOM. Support is partial, and shadow-root rendering remains a steady source of open issues.
Then there's the project itself. The last release, v1.4.1, shipped in January 2022. The README still opens with a warning that the library is "in a very experimental state" and "I don't recommend using it in a production environment." The repo carries close to a thousand open issues, and the community has responded the way communities do: a maintained fork and modern successors (more on SnapDOM below). And all of it costs real weight: the minified build is ~198 KB, about 45 KB gzipped — you're shipping a partial browser renderer to every visitor.
None of this makes html2canvas useless. It makes it a renderer with known blind spots — fine when approximation is acceptable, wrong when the screenshot is the evidence.
How does getDisplayMedia capture a screenshot?
Call navigator.mediaDevices.getDisplayMedia() from a click handler, pipe the returned MediaStream into an off-screen <video>, draw one frame onto a canvas, and stop the tracks. About twenty lines, zero dependencies — and the output is whatever the compositor actually rendered: iframes, WebGL, video frames, exotic CSS and all.
This is a complete, runnable capture function:
async function captureTab() {
// The permission prompt appears here — must be called from a user gesture.
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
preferCurrentTab: true, // Chromium 94+; ignored elsewhere
});
try {
// Pipe the stream into an off-screen <video> to get a frame.
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
await video.play(); // resolves once frames are flowing
// Draw the current frame onto a canvas.
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
return canvas.toDataURL('image/png');
} finally {
// Stop capture immediately — the "sharing this tab" UI disappears.
stream.getTracks().forEach((track) => track.stop());
}
}
Wire it to a button. It cannot run on page load — the API requires transient activation, meaning a real user gesture:
document.querySelector('#report-bug').addEventListener('click', async () => {
const screenshot = await captureTab().catch(() => null);
if (screenshot) {
showAnnotationUI(screenshot); // your annotate-and-send flow
} else {
showTextOnlyForm(); // user declined, or the API is unavailable
}
});
Per MDN, the hard requirements are a secure context (HTTPS or localhost), a user gesture, and a per-capture permission grant — the browser will not let you persist it. The API has been in Chrome since 72, Firefox since 66, and Safari since 13, so on desktop it is boring, settled technology.
What does preferCurrentTab actually do?
preferCurrentTab: true makes the page's own tab the prominent, one-click choice in the share picker — but it's Chromium-only, shipped in Chrome 94. It matters because Chromium's default is the opposite: the selfBrowserSurface option (shipped in Chrome 107) now defaults to "exclude", hiding the current tab from the picker entirely (it exists to prevent the infinite-mirror effect in video calls). preferCurrentTab: true flips that.
Elsewhere you take what the picker gives you: Firefox's picker offers windows and screens but no tabs, and Safari hands capture off to the macOS-level screen-sharing picker — window or full screen, no tab option. You still get real pixels in every desktop browser — Chromium gets you a tight, one-click "this tab" capture, while Firefox and Safari may include browser chrome or neighboring windows. Unknown options are ignored, so the snippet above runs unmodified everywhere the API exists.
The UX cost — and why the fallback should be text-only
getDisplayMedia costs you: a permission prompt on every capture, a click to start, HTTPS, and mobile — caniuse shows no support in Safari on iOS, Chrome for Android, or Samsung Internet. For a feedback tool, we think that's the right price. The user already clicked "Report a bug," so the gesture is free. And the prompt is a feature: when a frame of someone's screen is about to leave their machine, an explicit browser-level consent step is something you want to be able to point at.
The tempting move is to paper over the gaps — declined prompts, mobile browsers — with a rasterizer fallback. We think that's the wrong call for bug reports specifically. You'd ship ~45 KB gzipped of repainter to every visitor to produce screenshots that are least accurate exactly where users report bugs most: visually complex pages, embeds, canvas-heavy views. A screenshot that confidently shows the wrong thing is worse than no screenshot — it sends whoever triages the report chasing a layout glitch that only exists in the rasterizer. Users barely report bugs as it is — most users never report the bugs they hit — so the few reports you do get had better be evidence you can trust. Fall back to a text-only report instead: smaller, honest, and still useful.
What @catch.dev/feedback does
We built our web feedback widget on exactly the flow above. A floating launcher button (the web has no shake gesture — that trigger belongs to shake-to-report on iOS) opens the reporter; the user captures the tab via getDisplayMedia with preferCurrentTab, draws on the screenshot to highlight the problem, types a note, and hits Send. Everything — capture, annotation, the note — stays in the browser until that click; nothing is uploaded beforehand. Where capture isn't available (declined prompt, insecure context, mobile browser), the reporter degrades to a text-only report instead of shipping a renderer.
import { CatchDev } from '@catch.dev/feedback';
CatchDev.start('ck_your_key', {
environment: 'production',
shortcut: 'ctrl+shift+f', // optional keyboard shortcut
});
That's the whole integration — zero runtime dependencies, rendered in a Shadow DOM so it can't collide with your styles. The package is in early access (not on public npm yet); the web feedback SDK docs cover setup, options, and the fallback behavior, and the free tier means trying it costs nothing — create an account and no credit card is involved.
When is html2canvas (or SnapDOM) the right choice?
Whenever capture must happen silently or automatically, DOM rasterization is the only option — getDisplayMedia categorically cannot run without a gesture and a prompt, and no flag changes that. Legitimate cases:
- Automated capture — snapshotting state on an error, scheduled captures, anything with no user present to click.
- Element-to-image features — "download this chart as PNG" buttons, social-card generators, exporting a styled
<div>. - Mobile web — rasterization is the only screenshot mechanism that exists there at all.
- Prompt-intolerant flows — when a browser permission dialog would be unacceptable in context.
For new code in 2026, evaluate SnapDOM (@zumer/snapdom) before reaching for html2canvas: it serializes the DOM and renders it through SVG <foreignObject> instead of repainting properties by hand, it's dependency-free and actively maintained, and its own benchmarks show speedups over html2canvas — benchmark on your own pages, as vendors' numbers always flatter vendors. The maintained html2canvas fork is also a saner default than the dormant original. But know that every rasterizer shares the same structural ceiling: cross-origin iframes and tainted canvases are off-limits to JavaScript no matter how good the library is. Only the browser's own capture pipeline sees those pixels. (DRM-protected video defeats both — EME content typically renders black even in a real screen capture.)
| You need | Reach for |
|---|---|
| User-initiated bug/feedback screenshots on desktop | getDisplayMedia |
| Silent or automated capture | SnapDOM (or another DOM rasterizer) |
| Element-level export — chart → PNG | SnapDOM |
| Screenshots on mobile web | DOM rasterization — accept the inaccuracy |
| Faithful capture of iframes, video, WebGL | getDisplayMedia — rasterizers can't see them |
Pick by failure mode
Here's the lens that settled it for us: a rasterizer fails silently — you get a clean-looking screenshot that's subtly wrong, and nobody knows until an engineer wastes an afternoon on it. getDisplayMedia fails loudly — the prompt is declined or the API is missing, you get no image, and you can fall back honestly. For automated tooling, silent-but-approximate can be a fair trade. For bug reports, where the screenshot is the evidence, take the loud failure every time.