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 beginsAttempting 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
foreignObjectand lets the browser paint it. That is why it scored much higher. It missed onlybackdrop-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:
- The shadow needs a frame. Our first version captured
.badgedirectly and the shadow was cut off, because an element screenshot crops to the element's border box. Capturing a padded wrapper keeps it. - Transparency needs two switches.
background: transparentonhtmlandbodyremoves your white, andomitBackground: trueremoves the browser's default white. Either one alone gives a white or grey background. setContentshares a global scope between calls on one page. When we reused a page for a second document, a top-levelconstin its inline script threwIdentifier 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
- HTML to Image API: Render HTML and CSS to PNG Send HTML and CSS, get a PNG, JPEG, WebP or PDF back from real Chrome. Base URLs for assets, web fonts, transparent backgrounds and signed URLs.
- Best HTML to Image APIs (2026): 9 Options Compared The best HTML to image APIs compared: rendering engine, ready signals, fonts, delivery, public links and prices at three volumes, from each vendor's own docs.
- html2canvas Alternative: Render the DOM in Real Chromium We tested html2canvas 1.4.1 against real Chromium on 14 CSS features. See what it drops, when it is still the right tool, and how to move capture server-side.
- Convert a Webpage to an Image: 5 Methods, Measured Convert a webpage to a PNG, JPEG or WebP five ways, from a DevTools command to an API. Effort, cost and timings for each, plus the settings that matter.
- HTML to Image in Node.js: 4 Ways, Same 6 Templates HTML to image in Node.js with Puppeteer, node-html-to-image, satori and an API, run on the same six templates. Measured times, what broke, and code that works.
- HTML to Image in Python: imgkit, html2image, Playwright Convert HTML to an image in Python with html2image, Playwright or an API, compared on five templates with timings and renders, and why imgkit is a dead end.