Chrome 157 Puts Real HTML Inside Canvas and Retires the Overlay Hack
For twenty years, building a visually heavy web app meant picking a side. You got the pixel pipeline and GPU shaders from <canvas>, or you got text selection, screen readers, and right-click menus from HTML. The new HTML-in-Canvas API, stable in Chrome 157 since November 4, 2026, drops the requirement. Real DOM nodes, with real layout and real accessibility, now render inside 2D, WebGL, and WebGPU contexts.
This is not a screenshot trick. The elements stay interactive. You can select their text, translate them, autofill their form controls, search them with Find-in-Page, and inspect their CSS in DevTools. A screen reader walks the accessibility tree of content drawn inside a 3D scene. Crawlers and AI agents can index it. Chrome ran an origin trial first, and the October 26 announcement marks the shipped, stable version.
The problem it actually solves
Every canvas app that needed readable text faced the same two bad options. The first was drawing text as pixels with fillText(). That gives you total control and zero platform: no wrapping, no bidirectional text, no selection, and a screen reader hears nothing unless you build and sync an invisible fallback DOM by hand. Most teams skipped the fallback and shipped an inaccessible app.
The second option was the floating overlay. You absolutely positioned regular HTML elements on top of the canvas and ran a per-frame loop recalculating bounding boxes and matrix transforms, then wrote the result to style.transform. Everyone who tried this knows the failure modes. The overlay drifts during camera movement because DOM paint and canvas paint land on different frame timings. The overlay always sits on top of the entire canvas buffer, so you can never place a panel behind a 3D object or reflect it in a virtual mirror. And every overlay adds main-thread work: the browser parses a transform string and recalculates styles per element per frame, so cost grows linearly with UI count.
How the API works
The setup is declarative. You mark the canvas with content="drawable", which tells the browser to lay out its descendant DOM nodes instead of ignoring them, and you mark specific elements with a drawable attribute to make them independent paint roots.
<canvas id="main" width="600" height="400" content="drawable">
<div id="card" drawable>
<h2>Container Title</h2>
<p>Text inside the card.</p>
<button id="action" drawable>Action</button>
</div>
</canvas>A drawable element and its descendants form a drawable subtree, except any nested descendant that also carries drawable, which gets excluded and drawn by its own call. That nesting is what lets you keep semantic HTML while layering a button independently over its parent card in the scene.
Drawing in 2D is one method:
const ctx = canvas.getContext("2d");
canvas.onpaint = () => {
ctx.reset();
ctx.drawElementImage(card, 20, 20);
ctx.drawElementImage(action, 40, 90);
};drawElementImage() accepts the same overload family as drawImage(): a basic three-argument position form, a five-argument scaled form, and seven- and nine-argument forms that crop a source region before placing it. So you can render just the visible slice of a tall DOM panel without changing the DOM at all.
The paint event replaces your dirty-checking
The browser dispatches a paint event on the canvas whenever layout, text, or style on any drawable descendant changes. The event object carries changedElements, a list of only the descendants that visually changed since the last paint. No IntersectionObserver, no mutation observer, no hash of your own render state. You clear, redraw, done.
One lifecycle rule trips people up: canvas draw calls inside a paint handler appear in the current rendering update, but DOM mutations made inside that handler do not show up until the next frame. Setting style.backgroundColor inside onpaint schedules a new layout cycle, which fires another paint, which can loop if you are not careful. Treat paint handlers as read-only with respect to the DOM.
If you nest drawables inside a nested <canvas content="drawable">, the browser fires paint in reverse DOM order, descendants before ancestors, so the child canvas has already rendered by the time the parent composites it. That ordering is the whole point; rely on it rather than hand-rolling a frame scheduler.
Geometry, hit-testing, and the 3D path
In 2D contexts the browser keeps hit-testing, accessibility bounds, and drawn pixels automatically in sync, which is exactly the work the overlay hack did badly. In WebGL and WebGPU you take the wheel: you build a DOMMatrix and pass it to updateElementGeometry(), so your existing camera and model matrices position the DOM. The browser skips CSS parsing and style recalculation for those elements entirely. This is the path that puts a readable HTML terminal on the wall of a WebGPU scene, or an in-game menu that a screen reader can actually reach.
There is a manual escape hatch in 2D too: pass { preserveElementGeometry: true } to drawElementImage() to stop the browser from updating hit-test coordinates, then set geometry yourself. The docs demonstrate this specifically by showing how badly things desync when you misuse it. Default behavior is right for almost everyone.
Elements that have no rendered geometry yet still reach the accessibility tree, just without bounds. That lets a screen reader read a figure and caption drawn by separate calls while visual assistive tools filter the undrawn ones.
Ship it or hold off
Chrome 157 means Chrome and Edge. Firefox and Safari have shipped nothing, and the spec lives in a WICG repository with no other vendor publicly committed. Feature-detect honestly:
function supportsHtmlInCanvas() {
const canvas = document.createElement("canvas");
canvas.setAttribute("content", "drawable");
return canvas.content === "drawable";
}Keep your overlay or three-html-render fallback path alive for Firefox and Safari users. Chrome's own docs list html2canvas, dom-to-image, and floating overlays as fallbacks, which tells you the ecosystem is not migrated yet. Also budget for unit conversion: canvas grid pixels and CSS pixels differ under device-pixel-ratio scaling and resize, and the docs push you toward a ResizeObserver to maintain the scale factor yourself.
Adopt now if you own a canvas-heavy app and your analytics say Chromium above 90 percent. Chart annotations, canvas-native form panels, and export via captureStream() of composites containing live DOM are wins today. Hold off if the DOM UI is incidental, or if accessibility was already handled with a synced fallback DOM that still works everywhere. The overlay hack is retired in exactly one browser engine, and until Firefox and Safari move, the migration cost still lands on the users you cannot serve natively.
Comments