---
title: "How to Convert HTML to an Image in JavaScript"
description: "Render HTML as a PNG with Puppeteer or Playwright in Node.js, capture one element, wait for assets, or use the typed HTML/CSS to Image client."
published: "2026-10-14"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/convert-html-to-image-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](https://github.com/bubkoo/html-to-image){target=_blank} 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](https://phantomjs.org/){target=_blank}.

## 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.

```html
<!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](https://content-images.htmlcsstoimage.com/media-assets/5d34823024884962707ce587f834fc2e72f240d22c37c6cdbf25c60eb9825a95/example-output.png)

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](https://pptr.dev/guides/system-requirements){target=_blank} list the supported Node.js versions.

::: tabs
@tab Puppeteer

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

```bash
npm install puppeteer
```

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

```js
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();
}
```

@tab Playwright

Playwright installs the library and the browser separately:

```bash
npm install playwright
npx playwright install chromium
```

Save this as `render-playwright.mjs`:

```js
import { readFile } from "node:fs/promises";
import { chromium } from "playwright";
import { waitForAssets } from "./wait-for-assets.mjs";

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

const browser = await chromium.launch();

try {
  const page = await browser.newPage({
    viewport: { width: 900, height: 600 },
    deviceScaleFactor: 1,
  });

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

  await waitForAssets(page);

  await page.locator("#product-card").screenshot({
    path: "product-card.png",
    animations: "disabled",
  });
} finally {
  await browser.close();
}
```
:::

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

::: tabs
@tab Puppeteer

```bash
node render-puppeteer.mjs
```

@tab Playwright

```bash
node render-playwright.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](https://pptr.dev/guides/screenshots){target=_blank} has more capture options. Playwright is usually thought of as a test runner, but this script only uses its [library API](https://playwright.dev/docs/library){target=_blank}, 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:

```js
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()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decode){target=_blank} 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()`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/load){target=_blank} 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](/blog/wait-for-page-before-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:

```js
// 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](/blog/screenshot-specific-html-element) shows real examples of the difference.

To capture the whole document instead:

::: tabs
@tab Puppeteer

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

@tab Playwright

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

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](https://playwright.dev/docs/screenshots){target=_blank} 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.

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

Set `HCTI_API_ID` and `HCTI_API_KEY` in your server environment using credentials from the [dashboard](https://htmlcsstoimage.com/dashboard){target=_blank}, and keep them out of anything you bundle for the browser.

Save this as `render-hcti.mjs`:

```js
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`](https://docs.htmlcsstoimage.com/parameters/render_when_ready/){target=_blank} 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.

## Downloads and Related Guides

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

- [Complete product card HTML](https://content-images.htmlcsstoimage.com/media-assets/05505c5872e5f4725512c8867c8d1b26e5bda7b71c18b27db0b62bfd9399b70e/product-card.html)
- [Asset-waiting helper](https://content-images.htmlcsstoimage.com/media-assets/6bf43abb19f99d2333dacf7730f05c59fd4613d359d0fc48e02e3ecd23bfd1b2/wait-for-assets.mjs)
- [Puppeteer script](https://content-images.htmlcsstoimage.com/media-assets/a0c020701505a80fa77f7529d0a99c3e557560be2bc74975f4ef8966cc05b14c/render-puppeteer.mjs)
- [Playwright script](https://content-images.htmlcsstoimage.com/media-assets/178e508d6e974dbbec7032e5cdcacb7a07cfc9c3985eb07cec3f19945c8fa4f7/render-playwright.mjs)
- [Typed HCTI client script](https://content-images.htmlcsstoimage.com/media-assets/6f4fbe6e415434ff4d5721ac846f7b9e4da59ad5f92682caa1220d5b212f5baf/render-hcti.mjs)

For API parameters and more examples, see the [HTML-to-image API](https://htmlcsstoimage.com/features/html-to-image){target=_blank}, [HTML to PNG with the Node.js/TypeScript client](https://htmlcsstoimage.com/features/html-to-png/typescript){target=_blank}, the [JavaScript documentation](https://docs.htmlcsstoimage.com/example-code/javascript/){target=_blank} and the [typed client on GitHub](https://github.com/htmlcsstoimage/ts-client){target=_blank}.
