HTML in Canvas
The first native API that draws real HTML into a canvas context. Not a screenshot, not a workaround. Documenting what I've tried, what surprised me, and why this one matters.
I have been spending evenings with drawElementImage. It is the 2D path of the HTML-in-Canvas proposal: the entry point that does not need shaders, does not need WebGL, just the canvas context you already know.
The question that kept pulling me back: what changes when an HTML element lives inside a canvas effect instead of next to it? Not overlaid on top. Not screenshot-captured into pixels. Inside the canvas, in the DOM and in the pixel buffer at the same time.
These are the experiments I ran to find out.
The proposal
HTML-in-Canvas is a WICG proposal, not a shipped feature. It is in Chrome Canary behind chrome://flags/#canvas-draw-element and the API surface is still moving.
The proposal exposes two paths:
2D path. drawElementImage(element, x, y) on a CanvasRenderingContext2D. Works like drawImage but the source is a live, laid-out HTML element. Returns a DOMMatrix to feed back to element.style.transform, keeping the DOM hit area aligned with where the pixels landed.
WebGL path. texElementImage2D(...) on a WebGLRenderingContext. Uploads the element’s rendering as a GPU texture. The element becomes shader input.
Both paths share three primitives:
layoutsubtree on the canvas element opts children into layout but out of page painting. They exist in the DOM, are focusable, accessible, and selectable. They just do not paint themselves to the page.
The draw method moves pixels from the laid-out element into the canvas pixel buffer.
The paint event fires when a child’s rendering changes. That is your redraw signal. Kick off the first frame with canvas.requestPaint().
The hack I’ve always done
Every time I’ve needed rich text inside a canvas (a chart label with mixed weights, a tooltip with proper typography, an export feature that had to look like the UI) I’ve done some version of the same thing: position an HTML element absolutely on top of the canvas, calculate offsets manually, and pray it stays aligned when the canvas resizes. It never does. Not perfectly.
The other option is drawText. Which gives you this:
// one font per drawText call, no mixed weights,
// no letter-spacing, no ligatures, no <em>
ctx.font = 'bold 32px sans-serif';
ctx.fillText('+18.4%', cx, 62);
ctx.font = '12px sans-serif';
ctx.fillText('conversion Q1', cx, 86); // can't bold just "Q1"
The number on the canvas side doesn’t even look like the same piece of information. HTML-in-Canvas draws the right panel into the canvas. Natively. One call.
How it actually works
Three things you add to an existing canvas setup.
The layoutsubtree attribute on the <canvas> element tells the browser to lay out its direct children normally but skip painting them. They exist in the DOM, they’re focusable, they participate in hit testing. They just don’t paint themselves.
drawElementImage(element, x, y) draws one of those children into the canvas at the coordinates you give it. It returns a DOMMatrix. You hand that straight back to element.style.transform and the DOM position snaps to match where it was drawn, so pointer events still land correctly.
The paint event fires when any child’s rendering changes. That’s your redraw signal.
const canvas = document.querySelector('canvas');
canvas.setAttribute('layoutsubtree', '');
const card = document.createElement('div');
card.className = 'metric-card';
canvas.appendChild(card);
canvas.addEventListener('paint', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawSparkline(ctx); // my actual chart
// draw the HTML element into the same pixel buffer
const matrix = ctx.drawElementImage(card, 24, 20);
// sync DOM position so clicks still work
card.style.transform = matrix.toString();
});
The thing I keep forgetting: requestPaint() to kick it off the first time. The paint event doesn’t fire until something changes, so on mount you get nothing until you call it manually.
The part I keep thinking about
texElementImage2D. It binds a layoutsubtree child as a live WebGL texture.
canvas.addEventListener('paint', () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texElementImage2D(
gl.TEXTURE_2D, 0,
gl.RGBA, gl.RGBA,
gl.UNSIGNED_BYTE,
card
);
// card is now a texture. use it in your shader.
render();
});
Real CSS rendered as a GPU texture. A styled component as input to a shader. The browser handles layout and font rendering. I handle what happens to those pixels after.
This is the part I can’t stop thinking about. Real CSS rendered as a GPU texture. A styled component as input to a shader. The browser handles layout and font rendering. You handle what happens to those pixels after.
The wave displacement hits the HTML card and the aurora in the same pixel buffer at the same time. This is the demo. Enable chrome://flags/#canvas-draw-element in Chrome Canary if you haven’t yet:
chrome://flags/#canvas-draw-elementEnable the flag above in Chrome Canary, then reload.
canvas.setAttribute('layoutsubtree', '');
canvas.appendChild(card); // real HTML element
canvas.addEventListener('paint', () => {
const t = performance.now() - start;
ctx.clearRect(0, 0, W, H);
drawAurora(t);
// HTML element drawn into the pixel buffer
const matrix = ctx.drawElementImage(card, cx, cy);
card.style.transform = matrix.toString(); // keep DOM hit target aligned
requestAnimationFrame(() => canvas.requestPaint());
});
canvas.requestPaint();
If the flag is on, the card is a real HTML element drawn into the canvas. Select the text, tab to it. It’s still in the DOM. That’s the thing drawText can never do. The displacement on top of that is what texElementImage2D opens up, and that’s the part I’m still working through.
Getting here took a few wrong turns, and most of them came from knowing canvas too well.
My first instinct was to treat drawElementImage like drawImage: call it whenever I needed the element rendered. It doesn’t work that way. The paint event is what signals the browser has finished laying out the child and it’s safe to draw. Call drawElementImage outside that callback and you get nothing, no error, just silence. Familiar feeling from OffscreenCanvas work: the browser isn’t going to tell you what you did wrong.
The transform matrix was where my mental model broke. I expected it to give me the element’s bounding box in canvas coordinate space, useful for compositing or hit testing against other canvas content. What it actually returns is the style.transform to apply back to the element itself, so the DOM click target aligns with where the pixels landed. The directionality is the opposite of what I assumed. Once I understood what it was for rather than what I thought it should be, it clicked.
The WebGL path is where I’m still working through it. The coordinate space between the element’s CSS pixels and the WebGL texture’s physical pixels doesn’t resolve the way I expected from previous WebGL work, especially on retina screens. The 2D context is straightforward: same devicePixelRatio scaling as any high-res canvas setup. The texture mapping is not.
The animation loop took me longer than it should. My first instinct was to chain requestPaint() inside requestAnimationFrame inside the paint callback:
canvas.addEventListener('paint', () => {
draw();
requestAnimationFrame(() => canvas.requestPaint());
});
In the current Canary build the chain breaks somewhere. The aurora rendered once and stopped. The fix was to separate the two concerns: requestAnimationFrame owns the timing and calls requestPaint every frame, the paint handler only draws. Keeping them independent makes the loop reliable.
canvas.addEventListener('paint', () => {
ctx.clearRect(0, 0, W, H);
drawAurora(currentT); // time updated by rAF, not by paint
const matrix = ctx.drawElementImage(card, cardX, cardY);
card.style.transform = matrix.toString();
});
function animLoop() {
currentT = performance.now() - start;
canvas.requestPaint();
requestAnimationFrame(animLoop);
}
requestAnimationFrame(animLoop);
Whether this is the intended pattern or a quirk of an early implementation I don’t know yet. But it works.
When the canvas is the DOM
Creative coding has always made you choose. Canvas gives you the effect. HTML gives you the accessible content.
drawElementImage removes the choice. These three experiments use the real API. Enable chrome://flags/#canvas-draw-element in Chrome Canary. Without the flag, each falls back to canvas-drawn text or a static state.
Iris reveal
Two HTML states live in the canvas as layoutsubtree children. Click anywhere to expand an iris from the click point, revealing the second state underneath. The revealed text is real HTML: select it, copy it, it is in the DOM from the first frame.
chrome://flags/#canvas-draw-elementEnable the flag in Chrome Canary, then reload.
canvas.setAttribute('layoutsubtree', '');
canvas.appendChild(cardA); // summary state
canvas.appendChild(cardB); // detail state -- both in the DOM from the start
canvas.addEventListener('paint', () => {
drawBackground(ctx);
ctx.drawElementImage(cardA, cardAX, cardAY);
// iris clip: only cardB pixels inside the circle are drawn
ctx.save();
ctx.beginPath();
ctx.arc(clickX, clickY, easeOut(progress) * maxRadius, 0, Math.PI * 2);
ctx.clip();
ctx.drawElementImage(cardB, cardBX, cardBY);
ctx.restore();
});
canvas.addEventListener('click', e => {
clickX = e.offsetX;
clickY = e.offsetY;
targetProgress = targetProgress > 0.5 ? 0 : 1;
});
Form with canvas focus ring
Real <input> elements inside layoutsubtree. Tab between them or click to focus. A spring-animated ring in the canvas follows the focused field. The inputs are still interactive: type in them. The canvas drives the visual, the DOM drives the behaviour.
chrome://flags/#canvas-draw-elementEnable the flag in Chrome Canary, then reload.
canvas.setAttribute('layoutsubtree', '');
canvas.appendChild(form); // real form with real inputs
// track focused element
form.addEventListener('focusin', e => {
targetX = formX + e.target.offsetLeft + e.target.offsetWidth / 2;
targetY = formY + e.target.offsetTop + e.target.offsetHeight / 2;
});
canvas.addEventListener('paint', () => {
// spring step -- runs every frame
velX += (targetX - glowX) * 0.16; velX *= 0.62; glowX += velX;
velY += (targetY - glowY) * 0.16; velY *= 0.62; glowY += velY;
drawDotGrid(ctx);
drawGlowRing(ctx, glowX, glowY, glowW, glowH);
// form drawn on top -- inputs still accept focus and keyboard input
const m = ctx.drawElementImage(form, formX, formY);
form.style.transform = m.toString();
});
Accessible chart
Canvas draws the bars. The axis labels and values are real HTML elements: selectable, copyable, readable by screen readers. This is the most practical argument for the API. Every chart you have ever built with fillText has inaccessible labels. These do not.
chrome://flags/#canvas-draw-elementEnable the flag in Chrome Canary, then reload.
canvas.setAttribute('layoutsubtree', '');
// 12 real HTML label elements: 6 values + 6 months
const valLabels = values.map(v => {
const el = document.createElement('span');
el.textContent = v + '%';
canvas.appendChild(el);
return el;
});
canvas.addEventListener('paint', () => {
drawBars(ctx);
values.forEach((v, i) => {
// positioned at the top of each bar
const m = ctx.drawElementImage(valLabels[i], barCenterX(i), barTopY(v) - 15);
valLabels[i].style.transform = m.toString();
});
// month labels positioned at the bottom
});
Still open
One thing I haven’t measured yet: what happens when a layoutsubtree child has a CSS animation running. Does the paint event fire every frame? Is it throttled to the animation frame rate? I need to know this before I use it for anything that cares about performance.
Available now in Chrome Canary behind chrome://flags/#canvas-draw-element. Spec and demos at html-in-canvas.dev.
Still in it.