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:
- Wrap HTML in SVG foreignObject — embed the HTML string inside an SVG element
-
Encode as a data URI — convert the SVG to a
data:image/svg+xmlURL - Load into an Image element — the browser renders the SVG (including the embedded HTML)
-
Draw onto Canvas and export —
drawImage()copies the rendered result to a canvas, thentoDataURL()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>`;
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
widthandheighton 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);
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;
When the browser loads this data URI as an image, it:
- Parses the SVG
- Encounters the
foreignObject - Renders the embedded HTML using its full HTML/CSS engine
- 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();
};
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;
};
~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) }}
/>
Why iframe instead of rendering the SVG directly? Two reasons:
- Isolation. The user's HTML cannot affect the parent page's styles or DOM.
- 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 likeposition: absoluteinside the foreignObject
Cross-browser differences
Firefox handles foreignObject slightly differently than Chrome. Most notably:
- Firefox is stricter about
xmlnsattributes - Safari has historically had bugs with foreignObject rendering (mostly fixed in recent versions)
- Canvas
toDataURLquality 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 },
];
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)