Render HTML to Canvas
There are two ways to render HTML into a <canvas>:
a DOM-screenshotting library like html2canvas, which
re-implements CSS rendering in JavaScript, or the native
drawElementImage() API from the WICG
HTML-in-Canvas spec, which has the browser paint real, live DOM
elements directly into the canvas.
html2canvas vs drawElementImage() at a glance
| html2canvas (library) | drawElementImage() (native) | |
|---|---|---|
| Fidelity | Approximation — CSS is re-implemented in JS, so ligatures, subpixel text, some filters, and newer CSS features render differently or not at all | The browser's own renderer paints the element — output matches what the page shows, fonts and layout included |
| Performance | Clones the DOM and rasterizes in JavaScript — typically tens to hundreds of milliseconds per capture on real pages | Uses the browser's paint pipeline; fast enough to redraw every frame for animation and video capture |
| Live / interactive content | One-shot static snapshot; re-capture for every change |
Continuous — the paint event fires when a child's
rendering changes, and hit testing plus the accessibility tree
keep working via layoutsubtree |
| Browser support | All modern browsers |
Chromium dev trial — Chrome Canary and Brave Stable behind the
canvas-draw-element flag; no Firefox or Safari
implementation yet
|
| Bundle size | ~45 kB min+gzip shipped to every visitor | 0 kB — built into the browser |
The html2canvas approach — and its limits
html2canvas has been the standard answer for over a decade, and for good reason: it works in every browser today, needs no flags, and captures (almost) any element on the page with one call:
import html2canvas from 'html2canvas';
const target = document.querySelector('#card');
const canvas = await html2canvas(target);
document.body.appendChild(canvas);
// or export it:
const png = canvas.toDataURL('image/png'); Under the hood it does something heroic: it walks the DOM, reads every computed style, and redraws the whole tree onto a canvas using 2D drawing commands — effectively a CSS renderer written in JavaScript. That design is also the source of its limits:
- It's not the real renderer. Text is drawn with
fillText(), so ligatures, kerning, and subpixel antialiasing differ from the page. Unsupported or partially supported CSS (some filters, blend modes,conic-gradient, newer layout features) silently renders wrong. - Snapshots are static. Every change to the source element means another full capture — too slow for animation, interaction, or video.
- Large DOMs are slow. Cloning and rasterizing a big tree in JavaScript blocks the main thread, often for hundreds of milliseconds.
- Cross-origin content is a constant fight.
Cross-origin images taint the canvas or need a proxy;
iframecontent can't be captured at all. - It's a dependency. Roughly 45 kB of gzipped JavaScript that must keep chasing the CSS spec forever.
None of this is a knock on the library — it solves a problem the web platform simply had no API for. The HTML-in-Canvas proposal is that missing API.
The native way: layoutsubtree + drawElementImage()
The
WICG HTML-in-Canvas spec
adds a small API surface to <canvas>: a
layoutsubtree attribute that opts the canvas's children
into layout, a drawElementImage() context method that
paints a child into the canvas, and a paint event that
tells you when to redraw. A complete working example:
<canvas id="c" layoutsubtree>
<div id="content">Hello</div>
</canvas>
<script>
const ctx = c.getContext('2d');
// The paint event fires whenever a child element's rendering
// changes — redraw there, once per frame at most.
c.onpaint = () => {
ctx.reset();
const t = ctx.drawElementImage(content, 0, 0);
// Sync the DOM position with the drawn position so hit
// testing and accessibility keep working.
content.style.transform = t.toString();
};
// Size the canvas grid to device pixels to prevent blurriness.
new ResizeObserver(([e]) => {
c.width = e.devicePixelContentBoxSize[0].inlineSize;
c.height = e.devicePixelContentBoxSize[0].blockSize;
}).observe(c, { box: 'device-pixel-content-box' });
</script> Key behaviors to know:
-
The element must be a direct child of a canvas with
layoutsubtreeset. Children are laid out, hit-testable, and in the accessibility tree, but invisible until drawn. -
drawElementImage()mirrors thedrawImage()overloads — position, destination size, and source-rect variants — and applies the canvas's current transform. -
It returns a
DOMMatrix: apply it toelement.style.transformso the DOM position matches the drawn position (CSS transforms on the source element are ignored for drawing but still affect hit testing). -
WebGL and WebGPU get equivalents —
gl.texElementImage2D()andqueue.copyElementImageToTexture()— so HTML can be a live texture. Workers are covered viacaptureElementImage()and transferableElementImagesnapshots. - Cross-origin content, visited-link state, spellcheck markers, and other sensitive data are excluded from painting by design.
Full signatures and IDL are in the
API reference. The API is currently
a Chromium dev trial: enable
chrome://flags/#canvas-draw-element in Chrome Canary or
Brave Stable — the browser support
page has the two-minute setup.
Migrating from html2canvas
- Restructure the markup. html2canvas captures any
element anywhere;
drawElementImage()only draws direct children of alayoutsubtreecanvas. Move (or duplicate) the content you want to render inside the canvas element. - Replace capture calls with the paint loop. Instead
of
await html2canvas(el), draw inside the canvas'spaintevent and callrequestPaint()when you want a frame. For a one-shot export:
// One-shot export, html2canvas style: force a paint,
// draw, then read the pixels out.
c.addEventListener('paint', () => {
ctx.reset();
ctx.drawElementImage(content, 0, 0);
c.toBlob((blob) => downloadOrUpload(blob), 'image/png');
}, { once: true });
c.requestPaint(); - Export works the same. Once pixels are in the
canvas,
toBlob(),toDataURL(), andcaptureStream()behave exactly as before. - Drop the workarounds. Font-loading waits, cross-origin image proxies, and CSS-support shims can go — the browser renders its own layout, and cross-origin data is excluded (not tainted) by the spec.
- Keep html2canvas as the fallback. Until the API
ships beyond the flag, feature-detect and branch:
'drawElementImage' in CanvasRenderingContext2D.prototype.
See it running
Three live demos cover the common html2canvas use cases end to end:
- Hello World — the minimal
boilerplate: one styled
<div>drawn into a canvas, with annotated code. - HTML-to-Image Export — a social card generator that exports rich HTML as PNG/JPEG, the direct html2canvas replacement.
- HTML Video Recording —
records animated HTML to WebM via
captureStream()+MediaRecorder, something DOM screenshotting can't do.
Next steps: browse the full gallery of interactive HTML canvas examples, read the spec overview for the full design, or follow the flag setup guide to try the demos in your own browser.