· Tutorials · By Simon Chiu

Render HTML to PNG: 19 CSS Probes, 4 Renderers

Render HTML to PNG with a browser library, satori, headless Chrome or an API. We scored 19 CSS features per renderer with pixel probes. Code and results.

To render HTML to PNG with correct CSS, load the HTML into a real browser engine and take a screenshot. With Playwright that is page.setContent(html) followed by page.screenshot(). Libraries that skip the browser are lighter, but each one supports only part of CSS. We tested how large that part is: we scored four renderers on 19 CSS features with pixel probes, and this post publishes the results, the probe harness and working code for each path.

This is a spoke of the HTML to image API hub. If you need a "download as PNG" button that runs in the user's browser, the in-browser library comparison goes deeper on that side. For a Node-only tour of the same tools on full templates, see HTML to image in Node.js.

Tested on 2026-10-18 with Node 22.18.0 on macOS 27.0.1 (Apple M1 Pro): [email protected] (Chromium headless shell), [email protected], [email protected], [email protected] with [email protected] and @resvg/[email protected].

Three ways to turn HTML into pixels

Every HTML-to-PNG tool belongs to one of three families. The family decides which CSS works, more than the tool's own quality does.

Family Examples Who does layout and paint Runs where
DOM-to-canvas library html2canvas, html-to-image, modern-screenshot The browser lays out; the library repaints (html2canvas) or hands an SVG snapshot back to the browser (html-to-image) Inside a browser page
Own layout engine satori + resvg, @vercel/og The library, with its own subset of CSS Node, edge functions
Headless browser Playwright, Puppeteer, an HTML to image API Chromium, start to finish A server with Chrome, or a provider's servers

How we scored CSS support

Looking at screenshots and saying "close enough" does not scale and is easy to fool. So each probe is a 100 x 100 pixel document built so that the pixel at (55, 55) is green only if the feature works. If the renderer ignores the feature, a red background stays visible at that point. The harness reads that one pixel and records yes or no.

Two examples show the pattern:

// Fragment of the probe list; the full harness is further down.
const G = '#00c000';
const box = (style, inner = '') =>
  `<div style="position:relative;width:100px;height:100px;${style}">${inner}</div>`;

const probes = {
  // A 40px green square is centred only if grid centring works; otherwise it sits top-left.
  'grid centering': box('display:grid;place-items:center;background:red',
    `<div style="width:40px;height:40px;background:${G}"></div>`),
  // A red overlay disappears only if clip-path is applied.
  'clip-path': box(`background:${G}`,
    '<div style="position:absolute;inset:0;background:red;clip-path:circle(0)"></div>'),
};

The same HTML went through four renderers: Chromium via Playwright page.screenshot(), html2canvas and html-to-image running inside that same Chromium page, and satori with resvg in plain Node. Features that need a stylesheet (:has(), container queries, nesting) cannot be tested in satori at all, because satori reads inline style attributes only.

Results: 19 CSS features, 4 renderers

CSS feature Chromium (Playwright) html2canvas 1.4.1 html-to-image 1.11.13 satori 0.62.0
linear-gradient() yes yes yes yes
Flexbox gap yes yes yes yes
Grid centring (place-items) yes yes yes yes
grid-template-columns yes yes yes yes
Custom properties (var()) yes yes yes no
inset shorthand yes yes yes yes
transform yes yes yes yes
Inset box-shadow yes no yes yes
conic-gradient() yes no yes yes
aspect-ratio yes yes yes yes
clip-path yes no yes yes
mask-image yes no yes yes
filter yes no yes yes
backdrop-filter yes no no no
oklch() colour yes error yes yes
Inline <svg> yes yes yes yes
:has() selector yes yes yes no (needs <style>)
Container queries yes yes yes no (needs <style>)
CSS nesting yes yes yes no (needs <style>)
Score 19 / 19 12 / 19 18 / 19 14 / 19

What the numbers mean:

  • Chromium passed everything, which is the point of using a browser: the image matches what Chrome shows, because it is Chrome.
  • html2canvas repaints the page with its own canvas code. Selectors, grid and nesting work because the browser already computed the styles. Anything html2canvas must paint itself and has not implemented, such as filters, masks, clip paths and conic gradients, silently disappears. One oklch() colour made the whole render throw an error that begins Attempting to parse an unsupported color, so a single modern colour in your design system can break every export.
  • html-to-image clones the DOM into an SVG foreignObject and lets the browser paint it. That is why it scored much higher. It missed only backdrop-filter, which needs the real backdrop behind the element.
  • satori passed more than we expected, including both grid probes in this version. Its limits are of a different kind: no custom properties and no stylesheets, so your existing CSS does not carry over. You write templates for satori, not for the web. If you rely on grid in satori, test it on the exact version you pin.

A score is not a recommendation by itself. A social-card template that uses flexbox, gradients and one web font works in all four. A dashboard export full of design-system variables and modern colour functions works only in a real browser.

Recipe 1: headless Chromium (Playwright)

This complete script renders a badge to a transparent PNG at 2x density:

// html-to-png.mjs — render an HTML string to a PNG with Playwright.
import { chromium } from 'playwright';
import { writeFile } from 'node:fs/promises';

const html = `<!doctype html>
<html><head><meta charset="utf-8">
<style>
  html, body { margin: 0; background: transparent; }
  .badge { display: inline-flex; align-items: center; gap: 12px; padding: 16px 24px;
           border-radius: 999px; background: #0f172a; color: #f8fafc;
           font: 600 28px system-ui, sans-serif; box-shadow: 0 8px 24px rgb(0 0 0 / .25); }
  .frame { display: inline-block; padding: 24px; } /* room for the shadow */
  .dot { width: 16px; height: 16px; border-radius: 50%; background: oklch(0.75 0.2 145); }
</style></head>
<body><div class="frame"><div class="badge"><span class="dot"></span>Build passing</div></div></body></html>`;

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ deviceScaleFactor: 2 });
  await page.setContent(html, { waitUntil: 'load' });
  await page.evaluate(() => document.fonts.ready); // web fonts, if any, are in
  const png = await page.locator('.frame').screenshot({ omitBackground: true });
  await writeFile('badge.png', png);
  console.log(`badge.png: ${png.length} bytes`);
} finally {
  await browser.close();
}

It wrote a 590 x 226 RGBA PNG of 17,362 bytes. Three details in this script came from mistakes we made while writing it:

  1. The shadow needs a frame. Our first version captured .badge directly and the shadow was cut off, because an element screenshot crops to the element's border box. Capturing a padded wrapper keeps it.
  2. Transparency needs two switches. background: transparent on html and body removes your white, and omitBackground: true removes the browser's default white. Either one alone gives a white or grey background.
  3. setContent shares a global scope between calls on one page. When we reused a page for a second document, a top-level const in its inline script threw Identifier has already been declared. Wrap inline scripts in a block { ... }, or use a new page per template.

For many images, launch the browser once and reuse the page; the Puppeteer setContent guide shows timings for that pattern.

Recipe 2: an HTML to image API

When you do not want Chrome on your servers, send the HTML to an API. This RenderScreenshot request produces the same badge as a transparent PNG (the coloured dot is left out to keep the JSON short):

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- -o badge.png <<'JSON'
{
  "html": "<!doctype html><html><head><meta charset=\"utf-8\"><style>html,body{margin:0;background:transparent}.frame{display:inline-block;padding:24px}.badge{display:inline-flex;align-items:center;gap:12px;padding:16px 24px;border-radius:999px;background:#0f172a;color:#f8fafc;font:600 28px system-ui,sans-serif;box-shadow:0 8px 24px rgb(0 0 0/.25)}</style></head><body><div class=\"frame\"><div class=\"badge\">Build passing</div></div></body></html>",
  "viewport": { "width": 800, "height": 300, "scale": 2 },
  "capture": { "mode": "element", "selector": ".frame" },
  "output": { "format": "png", "transparent": true }
}
JSON

output.transparent works with PNG and WebP. capture.mode: element crops to the selector, so the frame trick applies here too. If your HTML references images or stylesheets by relative path, set html_base_url; the HTML to image guide explains this and the font-loading rules. Format and quality options are in the output options reference.

Recipe 3: in the browser with html-to-image

If the PNG must be made on the user's device, html-to-image was the stronger of the two in-page libraries in our probes. A minimal button handler:

import { toPng } from 'html-to-image';

document.querySelector('#download').addEventListener('click', async () => {
  const node = document.querySelector('#report');
  const dataUrl = await toPng(node, { pixelRatio: 2 });
  const a = Object.assign(document.createElement('a'), { href: dataUrl, download: 'report.png' });
  a.click();
});

Cross-origin images without CORS headers taint the canvas and break the export. The canvas tainting entry explains why, and the html2canvas vs html-to-image comparison covers the other differences.

Recipe 4: satori without a browser

satori turns a JSX-like tree into SVG, and resvg rasterises it. It needs at least one font file, because it does not read system fonts:

import satori from 'satori';
import { html } from 'satori-html';
import { Resvg } from '@resvg/resvg-js';
import { readFile, writeFile } from 'node:fs/promises';

const font = await readFile('./Inter-SemiBold.ttf'); // any TTF/OTF you are licensed to use
const tree = html(`<div style="display:flex;align-items:center;justify-content:center;
  width:1200px;height:630px;background:#0f172a;color:#f8fafc;font-size:64px">Hello</div>`);

const svg = await satori(tree, { width: 1200, height: 630, fonts: [{ name: 'Inter', data: font, weight: 600 }] });
await writeFile('card.png', new Resvg(svg).render().asPng());

Use inline styles only, and avoid var(). If a template needs either, you are fighting the renderer.

Run the probes on your own CSS

The full harness is about 100 lines. It needs playwright, html2canvas, html-to-image, satori, satori-html and @resvg/resvg-js. The core loop renders each probe in each engine and reads one pixel:

// Fragment: engines[] returns a PNG buffer; pixel() draws it on a canvas and reads (55, 55).
const classify = ([r, g, b]) => (g > r + 60 && g > b + 60 ? 'yes' : 'no');

for (const [name, probe] of Object.entries(probes)) {
  const row = { probe: name };
  for (const [engine, render] of Object.entries(engines)) {
    try {
      const png = await render(probe);
      row[engine] = png ? classify(await pixel(png)) : 'no (needs <style>)';
    } catch (err) {
      row[engine] = `error: ${err.message.slice(0, 60)}`;
    }
  }
  console.table([row]);
}

Add a probe for each CSS feature your templates depend on, and run it whenever you upgrade a library. Check each probe in Chromium first: a probe that fails or passes for the wrong reason tells you nothing. Our first :has() and container-query probes failed even in Chromium, because an inline red background beat the stylesheet rule. We moved the red into the stylesheet before recording the numbers above.

Troubleshooting

Symptom Cause Fix
White background instead of transparent Only one of the two switches set Transparent html, body and omitBackground (or output.transparent)
Shadow or glow cut off Element capture crops to the border box Capture a padded wrapper
Effects missing in html2canvas Not implemented in its painter Use html-to-image or a headless browser
Attempting to parse an unsupported color html2canvas and oklch(), lab() or similar Use hex or rgb() in exported nodes, or switch renderer
satori ignores your stylesheet satori reads inline styles only Inline the styles, or use a browser
Fallback font in the PNG Capture before the font loaded Wait for document.fonts.ready, or inline the font
Identifier has already been declared Reused page, inline const at top level Block-scope inline scripts, or use a new page
Blurry PNG on retina screens Rendered at 1x deviceScaleFactor: 2, pixelRatio: 2 or viewport.scale: 2

Which renderer should you choose?

  • Your HTML already exists and uses modern CSS: a headless browser, on your servers or through an API.
  • Templates written for the purpose, edge runtime, no browser allowed: satori, designed for its subset from day one.
  • The export must happen on the user's device: html-to-image, after checking your CSS against the probe list.
  • html2canvas: only for existing code that works today. In our probes it trailed html-to-image on every feature where they differed.

If you choose the API path, create a free account and send your own template through the request in recipe 2.

Related in this topic