SnapDOMGitHub8K
How-To Recipe
zumerlab/snapdom

HTML to PNG in JavaScript

Turn a rendered DOM element into a PNG you can preview, download or upload, with explicit control over pixel dimensions and capture fidelity.

TL;DR

await snapdom.toPng(element, { dpr: 2 }) returns a loaded HTMLImageElement whose src is a PNG data URL. Use snapdom.download() for a browser download or toBlob({ type: 'png' }) for upload bytes.

What is converted

SnapDOM starts from an element that already exists in a browser. It clones that subtree with the computed styles the browser resolved, inlines images and optional fonts, serializes the clone as HTML inside an SVG foreignObject, then rasterizes that SVG to PNG. The same pipeline handles pseudo-elements, CSS variables, open Shadow DOM, canvas content and same-origin iframes.

Your application still decides when to capture. Wait until data, animations and component rendering have reached the state you want in the file.

Install

npm install @zumer/snapdom@latest

Or load it from a CDN if you don't use a bundler:

<script type="module">
  import { snapdom } from "https://cdn.jsdelivr.net/npm/@zumer/snapdom/dist/snapdom.mjs";
</script>

Recipe 1: Capture and show a PNG preview

Wait for document fonts if the element uses them, then capture the rendered node. A fixed dpr makes the pixel output independent of the screen that ran the export.

import { snapdom } from '@zumer/snapdom';

const card = document.querySelector('#card');
if (!card) throw new Error('Missing #card');

await document.fonts.ready;

const image = await snapdom.toPng(card, {
  dpr: 2,
  embedFonts: true,
  backgroundColor: '#ffffff',
});

document.querySelector('#preview').replaceChildren(image);
console.log(image.naturalWidth, image.naturalHeight);

image.naturalWidth and image.naturalHeight are the encoded PNG dimensions. The returned image is already decoded, so it can be inserted immediately.

Recipe 2: Download a PNG file

The download helper rasterizes at DPR 1. Set the file dimensions with width, height or scale, and include the extension in filename.

await snapdom.download(card, {
  format: 'png',
  filename: 'product-card.png',
  width: 1200,
  embedFonts: true,
  backgroundColor: '#ffffff',
});

With only width set, SnapDOM calculates the height from the capture's aspect ratio. If you prefer to double the element's own dimensions, replace width with scale: 2.

Recipe 3: Preview and upload without capturing twice

Use the two-step API when one DOM clone needs more than one export. The capture and asset inlining run once; toPng() and toBlob() reuse the serialized result.

const capture = await snapdom(card, {
  dpr: 2,
  embedFonts: true,
});

const preview = await capture.toPng();
document.querySelector('#preview').replaceChildren(preview);

const blob = await capture.toBlob({ type: 'png' });
if (!blob) throw new Error('PNG encoding failed');

const form = new FormData();
form.append('file', blob, 'card.png');

const response = await fetch('/api/upload', {
  method: 'POST',
  body: form,
});

if (!response.ok) {
  throw new Error(`Upload failed: ${response.status}`);
}

toBlob() defaults to SVG. The explicit { type: 'png' } is what makes this Blob contain image/png bytes.

Control the output dimensions

SnapDOM first chooses a logical output size, applies scale, then multiplies the raster by dpr. For a capture with no extra visual bleed:

bitmap width = logical width × scale × dpr
bitmap height = logical height × scale × dpr
OptionEffectDefault
width / heightSets the logical output size. One dimension preserves aspect ratio; two dimensions force that exact shape.element size
scaleMultiplies the logical width and height.1
dprSets raster density without changing the returned image's CSS display size.devicePixelRatio
backgroundColorFills transparent pixels before encoding.transparent
qualityControls JPEG/WebP encoding. PNG ignores it because PNG is lossless.0.92

A 600 × 400 element captured with { dpr: 2 } produces a 1200 × 800 bitmap displayed at 600 × 400 CSS pixels. With { scale: 2, dpr: 1 }, the bitmap is also 1200 × 800, but its CSS display size is 1200 × 800.

// Sharp at the same display size
const retina = await snapdom.toPng(card, {
  width: 600,
  dpr: 2,
});

// Exact 1200px-wide file, independent of the device
const fixed = await snapdom.toPng(card, {
  width: 1200,
  dpr: 1,
});

Large values multiply quickly: doubling width and height creates four times as many pixels. Browsers cap a raster at 16,384 pixels per side; SnapDOM downscales an oversized uncropped export and logs a warning. See the high-resolution guide for density choices and the tiled capture guide when one bitmap is too large.

Try the three patterns live

Same element, three buttons: append the PNG, download it, or produce the upload-ready Blob:

HTML → PNG

Gradients, borders & shadows

Rounded corners, a dashed border, a gradient, captured exactly as rendered.

⚡ Even this shadow
The result will appear here.

PNG, JPEG or WebP

PNG keeps transparency and does not discard image data. The quality option has no effect on it. Use JPEG or WebP when you need a lossy size control; both default to a white background when the source has transparent areas.

const png = await capture.toPng();
const jpeg = await capture.toJpg({ quality: 0.85 });
const webp = await capture.toWebp({ quality: 0.85 });

If an API needs a Base64 data URL instead of a Blob, read png.src. It starts with data:image/png;base64,.

Diagnose a wrong or incomplete capture

SymptomCause and fix
External image is missingThe image host did not allow its bytes to be read cross-origin. Serve it from the same origin, add a valid Access-Control-Allow-Origin response, or set useProxy. An HTML crossorigin attribute cannot fix a server that omits CORS headers.
Custom font falls backWait for document.fonts.ready and pass embedFonts: true. Fonts registered at runtime with FontFace() should also be listed in localFonts.
Text wraps differentlyRetry with reconcile: true. SnapDOM mounts and measures the styled clone, which fixes rare re-wrap drift but adds another layout pass and can roughly double capture time.
Excluded controls leave gapsexclude uses hidden placeholders by default. Add excludeMode: 'remove' when the remaining content should close the space.
The PNG shows stale dataWait for the application's fetches, framework render and chart animation before calling SnapDOM. The library captures the current browser state; it does not decide when that state is complete.
Large capture is downscaledThe requested raster crossed the browser's 16,384-pixel side limit. Lower scale/dpr, set a smaller width/height, or rasterize the reusable capture in cropped bands.
Third-party iframe becomes a placeholderThe browser blocks access to cross-origin iframe documents. Same-origin frames are captured; a third-party page must be opened and captured inside a browser you control.

Set debug: true while diagnosing a capture to expose normally suppressed warnings:

await snapdom.toPng(card, { debug: true });

The complete option contract is in the options reference. For multi-format work, see the reusable capture API.

Frequently asked questions

How do I convert a rendered HTML element to PNG in JavaScript?

Install @zumer/snapdom and call snapdom.toPng(element). The promise resolves to a loaded HTMLImageElement whose src is a PNG data URL.

Why did toBlob return an SVG instead of a PNG?

SVG is the default Blob type. Pass { type: 'png' } to snapdom.toBlob() or result.toBlob() when you need image/png bytes.

How do I control the PNG pixel dimensions?

Set width or height for the logical output size, scale to multiply that size, and dpr for raster density. If you set only one dimension, SnapDOM preserves the aspect ratio.

Does the quality option reduce PNG file size?

No. PNG is lossless and ignores quality. Use toJpg() or toWebp() when you need a quality versus file-size control.

Can SnapDOM convert HTML to PNG in Node.js?

SnapDOM requires a real DOM. In Node.js, open the page with Playwright or Puppeteer, run SnapDOM inside that page, and return the data URL or encoded bytes to Node.

Run a real capture

Use the demo above, then install the same browser API in your application.

Open the demo Install from npm