How to Convert HTML to an Image in JavaScript

By Jeffrey Needles

October 14, 2026
Tutorial
Html to Image
Javascript

How to Convert HTML to an Image in JavaScript

JavaScript gives you two quite different ways to turn HTML into an image, and it's worth choosing on purpose.

You can export a DOM node in the visitor's own browser, using a library like html-to-image. Or you can load the HTML into a headless browser on your server with Puppeteer or Playwright and take a screenshot. Most of this post covers the server approach, because it gives you the same image every time, whatever browser and fonts your visitor happens to have.

We'll render a small "back in stock" card for an online store. We'll save just the card, then the whole page, and handle images and fonts that haven't finished loading. The last section does the same thing with HTML/CSS to Image's typed Node.js client, so you don't have to run a browser at all.

Server or Browser?

html-to-image runs entirely in the browser. It copies a DOM node into an SVG, draws that onto a canvas and gives you a PNG. That's perfect for a "download this chart" button on a page the user already has open.

The catch is that it runs on your visitor's machine. Fonts, rendering quirks and browser versions all depend on them, and cross-origin images and fonts need CORS headers before the canvas is allowed to read them.

If the image is generated by your app (for an email, a product feed or an Open Graph tag), render it on the server. Use Puppeteer or Playwright for that, not PhantomJS, which still appears in old Node tutorials even though its development is suspended.

1. Prepare the HTML

The example is a 640-pixel-wide product card with an inline SVG illustration and a system font. The CSS is inside the HTML file, so there's nothing external to fetch on the first run.

Save this as product-card.html. You'll create the script that renders it in the next step, in the same directory. The #product-card ID gives the renderer something stable to target.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Product availability card</title>
  <style>
    * { box-sizing: border-box; }
    body { margin: 0; padding: 24px; background: #edf1f5; font-family: Arial, sans-serif; color: #15233b; }
    .card { width: 640px; padding: 32px; border-radius: 16px; background: #fff; }
    .brand { margin: 0 0 28px; font-size: 14px; font-weight: 700; letter-spacing: .12em; color: #50617c; }
    .product { display: grid; grid-template-columns: 140px 1fr; gap: 28px; align-items: center; }
    .art { width: 140px; height: 140px; background: #e5f6ef; border-radius: 20px; display: grid; place-items: center; }
    svg { width: 94px; height: 94px; }
    h1 { margin: 10px 0 8px; font-size: 32px; line-height: 1.1; }
    .stock { display: inline-block; background: #e5f6ef; color: #146a49; border-radius: 6px; padding: 6px 10px; font-size: 13px; font-weight: 700; }
    .description { margin: 0; color: #50617c; font-size: 16px; line-height: 1.5; }
    .footer { margin-top: 30px; padding-top: 22px; border-top: 1px solid #e5eaf0; display: flex; justify-content: space-between; align-items: center; }
    .price { font-size: 28px; font-weight: 700; }
    .sku { font-size: 13px; color: #50617c; }
  </style>
</head>
<body>
  <article class="card" id="product-card">
    <p class="brand">RIDGE SUPPLY</p>
    <div class="product">
      <div class="art">
        <svg viewBox="0 0 100 100" role="img" aria-label="Green camping mug">
          <path d="M69 29h9a17 17 0 0 1 0 34h-9" fill="none" stroke="#146a49" stroke-width="8"/>
          <path d="M18 24h54v48a10 10 0 0 1-10 10H28a10 10 0 0 1-10-10Z" fill="#25845f"/>
          <path d="M15 24h60" stroke="#15233b" stroke-width="5" stroke-linecap="round"/>
          <path d="m33 59 12-20 12 20Z" fill="#fff"/>
        </svg>
      </div>
      <div>
        <span class="stock">BACK IN STOCK</span>
        <h1>Trail mug</h1>
        <p class="description">Enamel finish. Forest green. Ready for the next trip.</p>
      </div>
    </div>
    <div class="footer">
      <span class="price">$24.00</span>
      <span class="sku">SKU: MUG-FOREST-12</span>
    </div>
  </article>
</body>
</html>

Open it in your browser to check the design first. This is what the card looks like when rendered with HCTI, cropped to #product-card:

A product availability card for a green camping mug, rendered from the example HTML

The card's own padding is part of the image, but the page's outer padding isn't.

When you start generating this markup from product data, escape the values as you insert them. A product name containing < will otherwise break the layout, or worse.

2. Pick Puppeteer or Playwright

Both control Chromium, and both are fine for this. If one of them is already in your package.json, use that one. If you're starting from scratch, I'd lean toward Playwright. Its locators wait for elements automatically, and animations: "disabled" stops you from capturing a CSS transition halfway through. Puppeteer comes from the Chrome DevTools team and stays closer to the DevTools Protocol, which can help if you later need lower-level control.

Either way, run this on your backend or development machine, never in the customer's browser. Puppeteer's system requirements list the supported Node.js versions.

The standard puppeteer package downloads a compatible browser when you install it:

npm install puppeteer

Save this as render-puppeteer.mjs. Using the .mjs extension gets you imports and top-level await without touching package.json.

import { readFile } from "node:fs/promises";
import puppeteer from "puppeteer";
import { waitForAssets } from "./wait-for-assets.mjs";

const html = await readFile(
  new URL("./product-card.html", import.meta.url),
  "utf8",
);

const browser = await puppeteer.launch();

try {
  const page = await browser.newPage();

  await page.setViewport({
    width: 900,
    height: 600,
    deviceScaleFactor: 1,
  });

  await page.setContent(html, {
    waitUntil: "load",
    timeout: 30_000,
  });

  await waitForAssets(page);

  const card = await page.waitForSelector("#product-card", {
    visible: true,
  });

  await card.screenshot({ path: "product-card.png" });
} finally {
  await browser.close();
}

Both scripts import the waitForAssets() helper from the next section, so save that first, then run:

node render-puppeteer.mjs

You'll get product-card.png in your current directory, 640 pixels wide. Set the device scale factor to 2 and you get a 1,280-pixel image of the same layout.

If you turn this into a service, keep one browser running and give each job its own page or context. That's much faster than launching Chromium per image. Puppeteer's screenshot guide has more capture options. Playwright is usually thought of as a test runner, but this script only uses its library API, so you don't need a test suite.

3. Wait for Images and Fonts

The starter card doesn't load anything external, but a real catalog card will, usually a product photo from your CDN and a web font. If the screenshot runs before they arrive, you'll get a blank photo box or the wrong font.

Save this helper as wait-for-assets.mjs in the same directory:

export async function waitForAssets(page) {
  await page.evaluate(async () => {
    const assetsReady = async () => {
      await document.fonts.ready;

      await Promise.all(Array.from(document.images, image => {
        // Lazy images outside the viewport never start loading on their own
        image.loading = "eager";
        return image.decode();
      }));
    };

    let timer;

    try {
      await Promise.race([
        assetsReady(),
        new Promise((_, reject) => {
          timer = setTimeout(
            () => reject(new Error("Assets timed out")),
            15_000,
          );
        }),
      ]);
    } finally {
      clearTimeout(timer);
    }
  });
}

Everything inside page.evaluate() runs in the page, not in Node. That's why document is available. Both libraries wait for the promise it returns.

Here's what the helper does:

  • Uses decode() instead of complete. An image's complete property is also true after a failed request. decode() waits until the image can actually be drawn, and rejects if it can't. Any broken image fails the render. If some images are just decoration, give them a fallback rather than letting them block the export.
  • Sets loading = "eager" first. A loading="lazy" image below the fold won't start downloading by itself, so decode() would hang until the timeout.
  • Waits for document.fonts.ready. This waits for font loading to finish, but it can still resolve after the browser has given up and used a fallback. If one particular font matters, call document.fonts.load() for it and check that it returns a face.

The helper doesn't know about CSS background images, charts that are still animating, or content your app hasn't fetched yet. For those, wait for your app's own "done" state first. How to Wait for a Page Before Taking a Screenshot covers that.

Relative Asset Paths

setContent() has no base URL, so images/mug.png doesn't point anywhere. Use absolute HTTPS URLs, data URLs or a <base> tag. If the assets are on disk next to the HTML, navigate to the file instead:

// Works with either Puppeteer or Playwright.
await page.goto(
  new URL("./product-card.html", import.meta.url).href,
  { waitUntil: "load" },
);

If the page needs to fetch JSON or behave like a real site, run a local dev server and navigate to that URL.

4. Capture One Element or the Whole Page

The scripts above capture #product-card, so the gray page background is left out. An element screenshot includes the element's content, padding and border. Margins and box shadows fall outside it and get cut off. To keep a shadow, wrap the card in a padded container and capture the wrapper. The element screenshot tutorial shows real examples of the difference.

To capture the whole document instead:

await page.screenshot({
  path: "product-page.png",
  fullPage: true,
});

Leave out fullPage and you get the 900 × 600 viewport. With it, you get everything down to the bottom of the scrollable page. Playwright's screenshots documentation has examples of both.

5. Generate the Image with the HTML/CSS to Image API

Keeping a headless browser healthy on a server means dealing with crashes, memory use and Chromium updates, which takes more effort than people expect. If you'd rather not run one, our official Node.js client sends the HTML to us and returns a hosted image. It's written in TypeScript, but it works just as well from plain JavaScript.

npm install @html-css-to-image/client

Set HCTI_API_ID and HCTI_API_KEY in your server environment using credentials from the dashboard, and keep them out of anything you bundle for the browser.

Save this as render-hcti.mjs:

import { readFile, writeFile } from "node:fs/promises";
import {
  HtmlCssToImageClient,
  CreateHtmlCssImageRequest,
} from "@html-css-to-image/client";

const html = await readFile(
  new URL("./product-card.html", import.meta.url),
  "utf8",
);

const client = HtmlCssToImageClient.fromEnv();

const result = await client.createImage(
  new CreateHtmlCssImageRequest({
    html,
    selector: "#product-card",
    viewport_width: 900,
    viewport_height: 600,
    device_scale: 1,
    format: "png",
  }),
);

if (!result.success) {
  throw new Error(result.message ?? result.error);
}

console.log(result.url);

// Pass --download to save a copy with a separate, unauthenticated request.
if (process.argv.includes("--download")) {
  const response = await fetch(result.url, {
    signal: AbortSignal.timeout(30_000),
  });

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

  await writeFile(
    "product-card-hcti.png",
    Buffer.from(await response.arrayBuffer()),
  );
}

Run node render-hcti.mjs to print the image URL, or node render-hcti.mjs --download to save a PNG as well. Most of the time the URL is all you need, because you can put it straight into an email or a product page.

The selector, viewport and device_scale match the local scripts, so you should get the same 640-pixel card.

This static card doesn't need a ready signal. If yours renders asynchronously, turn on render_when_ready and call ScreenshotReady() from the page when it's done. If you turn it on and never call ScreenshotReady(), the request waits until it times out.

Frequently Asked Questions

Does This Work with TypeScript?

Yes. Puppeteer, Playwright and the HCTI client all ship with types. If you rename the scripts to .ts, type the helper's page argument with your library's Page type, and include the DOM lib so the code inside page.evaluate() type-checks.

Can I Save a Transparent PNG?

Yes. Remove the backgrounds you don't want in your CSS, then pass omitBackground: true to the screenshot call. With HCTI, set transparent_background: true. Both only remove the page's default white background, so the card's own background: #fff still needs to go.

Why Doesn't the Image Match My Browser Preview?

Check the viewport first. A responsive layout at 900 pixels wide may not match your 1440-pixel browser window. After that, check the device scale and fonts. A fallback font changes line breaks even when the HTML is identical.

Keep the HTML and helper in the same directory as whichever script you run:

For API parameters and more examples, see the HTML-to-image API, HTML to PNG with the Node.js/TypeScript client, the JavaScript documentation and the typed client on GitHub.

Loading newsletter signup…

Please wait a moment.

Have a question?

We'd love to hear about what you're building.

Contact us

Get Started

You'll be up and running in 5 minutes.

Grab an API Key

Keep reading

More posts

View all posts
Get Started Now

NO CREDIT CARD NEEDED. 50 FREE IMAGES EVERY MONTH.