DEV Community

Cover image for Convert HTML to PNG in the Browser Using SVG foreignObject (No Library)
Shaishav Patel
Shaishav Patel

Posted on • Originally published at ultimatetools.hashnode.dev

Convert HTML to PNG in the Browser Using SVG foreignObject (No Library)

There are plenty of libraries for converting HTML to an image — html2canvas, dom-to-image, modern-screenshot. They all do the same fundamental thing: serialize HTML into an SVG foreignObject, render it onto a <canvas>, and export the canvas as a PNG or JPEG.

But the underlying technique is simple enough that you do not need a library at all. I built an HTML to Image Converter using this approach in about 80 lines of code. Here is how the technique works, where it breaks, and what to watch for.


The Pipeline

The conversion follows four steps:

  1. Wrap HTML in SVG foreignObject — embed the HTML string inside an SVG element
  2. Encode as a data URI — convert the SVG to a data:image/svg+xml URL
  3. Load into an Image element — the browser renders the SVG (including the embedded HTML)
  4. Draw onto Canvas and exportdrawImage() copies the rendered result to a canvas, then toDataURL() exports it

Each step is native browser APIs. No DOM cloning, no computed style copying, no recursive tree walking.


Step 1: Wrapping HTML in SVG foreignObject

SVG has an element called <foreignObject> that allows you to embed non-SVG content — including full HTML — inside an SVG document. The browser renders the HTML as it normally would, but within the SVG coordinate system.

const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
  <foreignObject width="100%" height="100%">
    <html xmlns="http://www.w3.org/1999/xhtml">
      <head>
        <meta charset="utf-8"/>
        <style>
          * { box-sizing: border-box; }
          body {
            margin: 0;
            padding: 16px;
            width: ${width}px;
            min-height: ${height}px;
            background: #ffffff;
            font-family: Arial, sans-serif;
          }
        </style>
      </head>
      <body>${htmlCode}</body>
    </html>
  </foreignObject>
</svg>`;
Enter fullscreen mode Exit fullscreen mode

A few things to note:

  • The xmlns="http://www.w3.org/1999/xhtml" on the <html> element is required. Without it, the browser will not parse the content as HTML inside the SVG context.
  • The width and height on the SVG element define the output image dimensions.
  • The <style> block sets base styles. Since this is a self-contained document, there is no external CSS — everything must be inline or in this embedded <style>.

Step 2: Encoding as a Data URI

The SVG string needs to become a URL that an <img> element can load:

const svgDataUrl = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgContent);
Enter fullscreen mode Exit fullscreen mode

encodeURIComponent handles all the special characters in the HTML — quotes, angle brackets, ampersands. The result is a valid data URI that the browser can parse as an SVG image.

Why not use btoa() (base64)? Because btoa() fails on non-Latin characters. If the HTML contains emoji, CJK characters, or accented text, btoa() throws. encodeURIComponent handles all Unicode correctly.


Step 3: Loading Into an Image Element

const img = new Image();
img.onload = () => {
    // Step 4 happens here
};
img.onerror = () => {
    // Handle failure — usually a malformed SVG or CORS issue
};
img.src = svgDataUrl;
Enter fullscreen mode Exit fullscreen mode

When the browser loads this data URI as an image, it:

  1. Parses the SVG
  2. Encounters the foreignObject
  3. Renders the embedded HTML using its full HTML/CSS engine
  4. Produces a rasterized bitmap

This is the key insight: the browser's own rendering engine does all the work. You are not reimplementing CSS layout or text rendering — you are asking the browser to render HTML as it normally does, just inside an SVG context.


Step 4: Canvas Export

Once the image loads, draw it onto a canvas and export:

img.onload = () => {
    const canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext("2d")!;

    // Fill white background (JPEG needs this; PNG will have it too)
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, width, height);

    // Draw the rendered SVG
    ctx.drawImage(img, 0, 0);

    // Export
    const mimeType = format === "png" ? "image/png" : "image/jpeg";
    const dataUrl = canvas.toDataURL(mimeType, 0.95);

    // Trigger download
    const link = document.createElement("a");
    link.href = dataUrl;
    link.download = `html-to-image.${format}`;
    link.click();
};
Enter fullscreen mode Exit fullscreen mode

The 0.95 quality parameter applies only to JPEG. PNG ignores it (PNG is always lossless).

The white fillRect ensures JPEG exports have a white background instead of black. Without it, transparent areas in the SVG render as black in JPEG (since JPEG has no alpha channel).


The Complete Function

Here is the full export function, condensed:

const downloadImage = async (
    htmlCode: string,
    width: number,
    height: number,
    format: "png" | "jpeg"
) => {
    const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
        <foreignObject width="100%" height="100%">
            <html xmlns="http://www.w3.org/1999/xhtml">
            <head>
                <meta charset="utf-8"/>
                <style>
                    * { box-sizing: border-box; }
                    body {
                        margin: 0; padding: 16px;
                        width: ${width}px; min-height: ${height}px;
                        background: #ffffff; font-family: Arial, sans-serif;
                    }
                </style>
            </head>
            <body>${htmlCode}</body>
            </html>
        </foreignObject>
    </svg>`;

    const svgDataUrl = "data:image/svg+xml;charset=utf-8,"
        + encodeURIComponent(svgContent);

    const img = new Image();
    img.onload = () => {
        const canvas = document.createElement("canvas");
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext("2d")!;
        ctx.fillStyle = "#ffffff";
        ctx.fillRect(0, 0, width, height);
        ctx.drawImage(img, 0, 0);

        const link = document.createElement("a");
        link.href = canvas.toDataURL(
            format === "png" ? "image/png" : "image/jpeg",
            0.95
        );
        link.download = `html-to-image.${format}`;
        link.click();
    };
    img.src = svgDataUrl;
};
Enter fullscreen mode Exit fullscreen mode

~30 lines. No dependencies.


The Live Preview: iframe with srcDoc

Separately from the export pipeline, the tool shows a live preview using an <iframe> with srcDoc:

const iframeSrc = `<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <style>
        * { box-sizing: border-box; }
        body { margin: 0; padding: 16px; width: ${width}px;
               min-height: ${height}px; background: #ffffff; }
    </style>
</head>
<body>${htmlCode}</body>
</html>`;

<iframe
    srcDoc={iframeSrc}
    title="HTML Preview"
    sandbox="allow-same-origin"
    style={{ height: Math.min(height + 32, 400) }}
/>
Enter fullscreen mode Exit fullscreen mode

Why iframe instead of rendering the SVG directly? Two reasons:

  1. Isolation. The user's HTML cannot affect the parent page's styles or DOM.
  2. Accuracy. The iframe renders HTML exactly as a browser normally would. The SVG foreignObject export has some limitations (covered below), so the preview intentionally uses a different rendering path to show the most accurate representation.

The sandbox="allow-same-origin" attribute prevents script execution inside the iframe while still allowing CSS to render correctly.


Where foreignObject Breaks

This technique has real limitations. Understanding them matters more than the implementation:

External resources do not load

Images loaded via <img src="https://...">, fonts from Google Fonts CDN, and any other external resource will not be included in the exported image. The SVG data URI is a self-contained document — the browser will not make network requests to resolve external URLs inside it.

Workaround: Convert external images to base64 data URIs before embedding. For fonts, use @font-face with base64-encoded font data (large, but works).

JavaScript does not execute

Any <script> tags inside the HTML are ignored. The foreignObject renders static HTML and CSS only. This means dynamic content (Canvas drawings, JS-generated elements) will not appear.

CSS limitations

Most CSS works — flexbox, grid, gradients, transforms, shadows, border-radius. But a few things do not:

  • Pseudo-elements (::before, ::after) — inconsistent across browsers
  • CSS animations — the export captures a single frame (the initial state)
  • position: fixed — behaves like position: absolute inside the foreignObject

Cross-browser differences

Firefox handles foreignObject slightly differently than Chrome. Most notably:

  • Firefox is stricter about xmlns attributes
  • Safari has historically had bugs with foreignObject rendering (mostly fixed in recent versions)
  • Canvas toDataURL quality varies slightly between engines

Why Not Use html2canvas?

html2canvas takes a different approach: instead of using foreignObject, it traverses the live DOM, reads computed styles for every element, and manually redraws them onto a Canvas using Canvas 2D drawing commands.

This means html2canvas can capture the current state of a live page — including computed styles, applied JavaScript, and external resources that have already loaded. But it also means:

  • Large bundle size (~40KB minified)
  • Slow for complex DOMs — it walks every element and redraws it
  • Imperfect CSS support — it reimplements CSS rendering, so complex layouts (grid, certain flexbox patterns) may not match

The foreignObject approach offloads all rendering to the browser's native engine. It is faster, smaller, and more accurate for CSS — but it cannot capture live DOM state or external resources.

For a tool where users paste HTML code (not capture a live page), foreignObject is the right choice.


Preset Dimensions

The tool includes quick-size presets for common social media formats:

const presets = [
    { label: "Twitter Card", w: 1200, h: 628 },
    { label: "OG Image",     w: 1200, h: 630 },
    { label: "Square",       w: 800,  h: 800  },
    { label: "Story (9:16)", w: 1080, h: 1920 },
];
Enter fullscreen mode Exit fullscreen mode

These are the standard dimensions each platform expects for share images. Getting the dimensions right means the image will not be cropped or letterboxed when shared.


Wrapping Up

The SVG foreignObject technique is one of those browser features that feels like a hack but is actually well-specified and widely supported. It has been in the SVG spec since SVG 1.1 (2003) and works in all modern browsers.

The core idea — embed HTML in SVG, render SVG as an image, draw image on canvas, export canvas — is about 30 lines of code. No dependencies, no server, no DOM cloning. The browser does the hard work.

Try the tool: HTML to Image Converter

If you want to see how we handle other client-side processing challenges, check out how we moved PDF processing from server to browser — a different problem but the same zero-upload philosophy.

Top comments (0)