5 min read
0%

HTML in Canvas

Back to Blog
HTML in Canvas

HTML in Canvas

Rendering styled HTML markup directly on a <canvas> has always been the hack nobody wanted to document. The SVG <foreignObject> route works across modern browsers today; a native drawHTMLElement() method is in active standardization.

SVG foreignObject

The classic approach: serialize HTML into an SVG blob, draw the SVG onto an Image, then paint it onto canvas.

async function drawHTML(ctx, element, x, y) {
  const { width, height } = element.getBoundingClientRect();
  const html = element.outerHTML;

  const svg = `
    <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
      <foreignObject width="100%" height="100%">
        <div xmlns="http://www.w3.org/1999/xhtml">${html}</div>
      </foreignObject>
    </svg>`;

  const blob = new Blob([svg], { type: "image/svg+xml" });
  const url = URL.createObjectURL(blob);

  const img = await new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = reject;
    image.src = url;
  });

  ctx.drawImage(img, x, y);
  URL.revokeObjectURL(url);
}

The key gotcha: external resources (fonts, images, stylesheets) do not load inside foreignObject unless inlined. Computed styles must be cloned into style.cssText if you need pixel-perfect output.

Inline Styles Before Serializing

function cloneWithInlineStyles(element) {
  const clone = element.cloneNode(true);
  const originals = [element, ...element.querySelectorAll("*")];
  const clones = [clone, ...clone.querySelectorAll("*")];

  for (let i = 0; i < clones.length; i++) {
    const computed = getComputedStyle(originals[i]);
    clones[i].style.cssText = computed.cssText;
  }

  return clone;
}

Pass the clone’s outerHTML to drawHTML above and fonts, colors, and spacing all survive the round-trip.

OffscreenCanvas + Worker

For non-blocking renders, transfer the canvas to a worker:

// main.js
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen, html: el.outerHTML }, [offscreen]);

// worker.js
self.onmessage = async ({ data: { canvas, html } }) => {
  const ctx = canvas.getContext("2d");
  const { width, height } = canvas;

  const svg = `<svg xmlns="http://www.w3.org/2000/svg"
    width="${width}" height="${height}">
    <foreignObject width="100%" height="100%">
      <div xmlns="http://www.w3.org/1999/xhtml">${html}</div>
    </foreignObject>
  </svg>`;

  const blob = new Blob([svg], { type: "image/svg+xml" });
  const bitmap = await createImageBitmap(blob);
  ctx.drawImage(bitmap, 0, 0);
  bitmap.close();
};

createImageBitmap accepts SVG blobs directly — no intermediate Image element needed in a worker context.

Security Constraints

Browsers taint the canvas as soon as a cross-origin resource touches it. toDataURL() and getImageData() both throw after a tainted draw. Mitigations:

  • Host all assets on the same origin or a CORS-enabled CDN
  • Use crossOrigin = "anonymous" on images before drawing
  • Check canvas.mozOpaque / willReadFrequently hints for GC pressure
const ctx = canvas.getContext("2d", { willReadFrequently: true });

Native drawHTMLElement() — In Standardization

The Canvas 2D spec is working toward a first-class drawHTMLElement() method that eliminates the resource-loading and style-isolation limitations of the foreignObject route:

const ctx = canvas.getContext("2d");

// Proposed API — in development
await ctx.drawHTMLElement(document.querySelector(".card"), 0, 0, {
  width: 400,
  height: 300,
});

Unlike foreignObject, the native method has full access to the document’s resource cache — fonts, images, and external CSS all resolve. Origin security restrictions still apply.

Feature-detect before use:

const supportsDrawHTMLElement =
  "drawHTMLElement" in CanvasRenderingContext2D.prototype;

Use Cases

Screenshot-to-canvas — capture DOM nodes for image export, social sharing cards, or canvas-based print previews without a headless browser.

WebGL label textures — bake styled HTML labels onto THREE.CanvasTexture quads without maintaining parallel DOM overlays.

Canvas-based PDF generation — compose paginated HTML layouts and serialize to JPEG/PNG strips via canvas.toBlob().


Browser support snapshot

Live support matrix for canvas from Can I Use.

Show static fallback image Data on support for canvas across major browsers from caniuse.com

Source: caniuse.com

Canvas is not supported in your browser