HTML-in-Canvas: what it is, how it works, and how to use it
HTML-in-Canvas lets a canvas draw real HTML, and the text stays selectable, clickable and readable by screen readers. How it works, the code that runs in Chrome 157, which libraries broke, and where every browser stands.
By Syed Fazle Rahman20 min read
On this page
- What's new in Chrome 157
- Why HTML-in-Canvas exists
- How HTML-in-Canvas works
- What you can build with HTML-in-Canvas
- When to use HTML-in-Canvas, and when not to
- Your first HTML-in-Canvas page
- Getting HTML-in-Canvas right
- Library support
- The full HTML-in-Canvas API
- What HTML-in-Canvas won't draw, and why
- HTML-in-Canvas and accessibility
- Is HTML-in-Canvas good for the web?
- Browser support and standards status
- HTML-in-Canvas versus html2canvas
- If your code uses layoutsubtree
- Frequently asked questions
HTML-in-Canvas is a browser API that lets a <canvas> draw real HTML elements. You put a normal <div>, form or block of styled text inside the canvas. The browser lays it out and keeps it in the page. Then you draw it into the canvas wherever you want, in 2D, WebGL or WebGPU. The text you see is still text. You can select it, find it with Find, and a screen reader reads it. Chrome 157 turned it on for everyone on November 3, 2026.
We ran every snippet in this post in Chrome 157.0.8103.39 and copied the error messages from that run. The API changed twice during its origin trial. So a lot of the code online no longer works…
Status, November 5, 2026
- Works in: Chrome 157 and later. Edge 157 lists it in its release notes.
- Doesn't work in: Firefox and Safari.
- Standards: a WHATWG proposal at stage 3, with the spec change still open.
- Docs: Chrome's launch post, concepts and 2D guide are live. MDN's pages are still in review.
What's new in Chrome 157
Chrome 157 is the first stable release with HTML-in-Canvas on by default. Before it, you needed an origin trial token or a flag. And the version that shipped isn't the one most tutorials were written against:
- The canvas opts in with
content="drawable". The oldlayoutsubtreeattribute does nothing now. - Every element you want to draw needs a
drawableattribute. It can be any descendant of the canvas, not only a direct child. drawElementImage()returns nothing. In 2D it moves the element's real position to where you drew it. Clicks and screen readers follow on their own.- The WebGL and WebGPU methods were renamed.
texElementImage2DbecametexElementSubImage2D, andcopyElementImageToTexturebecamedrawElementImageToTexture.
Libraries built on the trial version broke in the same release. In their current releases, Three.js and PlayCanvas leave HTML textures blank on Chrome 157, and PixiJS throws an error. Library support has the status of each.
Why HTML-in-Canvas exists
For as long as the web has had <canvas>, you've had to choose. Canvas draws fast: thousands of shapes, shaders, 3D scenes. But anything it draws is pixels. Text drawn with fillText() can't be selected, copied, found, translated or read by a screen reader. HTML has all of that. But you can't put a <div> behind a 3D object, run a shader over it, or wrap it around a curved surface…
Apps have worked around it in three ways, and each one costs something:
- Drawing text yourself with
fillText(), or with a whole text engine. Figma draws its editor with its own GPU renderer. Google Docs moved its documents to canvas rendering in 2021. Flutter's web renderer draws to a canvas. Each of them rebuilds text layout, selection and accessibility by hand. - Laying HTML on top of the canvas and moving it every frame to match. It drifts during fast animation. It can't go behind anything in the scene. And every overlay costs work on the main thread.
- Taking a picture of the HTML with a library like html2canvas. It reads the DOM and redraws it with canvas calls. That's an approximation of the browser's own rendering, so some CSS comes out wrong. And the result is a static image.
HTML-in-Canvas removes the choice. The browser renders the real HTML, and you decide where the pixels go.
How HTML-in-Canvas works
In short: the browser keeps a snapshot of each drawable element, tells you when one changes, and points clicks and screen readers at wherever you draw it. Five parts make that work.
You opt in. <canvas content="drawable"> tells the canvas to lay out its children instead of treating them as fallback content. Then you mark each element you want to draw with drawable. Each one becomes its own paint root.
The browser takes a snapshot. On every rendering update, it records how each drawable element looks. Nothing gets laid out or painted when you call a method. You always draw the latest snapshot.
The paint event tells you when to draw. It fires on the canvas when the layout, text or CSS of a drawable element changes, at most once per frame. It carries changedElements, the drawable elements that changed since the last paint. You draw inside it, and the result shows in the same frame. DOM changes you make inside the handler land in the next frame. Style changes you want in this frame go in requestAnimationFrame, which runs before paint. And requestPaint() asks for a paint event on the next frame, even when nothing changed. Call it inside the handler to animate every frame.
You draw the snapshot. In 2D it's ctx.drawElementImage(). In WebGL you upload it to a texture with texElementSubImage2D(). In WebGPU it's drawElementImageToTexture().
The browser keeps clicks and screen readers in step. In 2D, drawing an element moves its real position to where you drew it. In our test, a card drawn at (50, 40) reported a getBoundingClientRect() at exactly (50, 40) from the canvas corner. So clicks, hover and the accessibility tree line up with the pixels. In WebGL and WebGPU the browser can't know where a texture ended up on your mesh. You tell it with canvas.updateElementGeometry().
The HTML-in-Canvas loop. It runs again whenever a drawable element changes.
What you can build with HTML-in-Canvas
The Chrome demos, the WICG examples and the community list show the range:
- Charts and diagrams with real text. Labels, legends and tooltips stay selectable and get translated with the page.
- Canvas editors and whiteboards whose text and form fields work with screen readers, autofill and browser extensions.
- HTML under shaders: blurs, ripples and distortions over a real login form or article.
- UI inside 3D and WebXR: a terminal on a wall in a game, a menu on a curved panel, a readable book in a 3D scene.
- HTML to images and video. A canvas holding live HTML still exports with
toBlob(),toDataURL()andcaptureStream(), which video tools are starting to use.
When to use HTML-in-Canvas, and when not to
| You want | Use |
|---|---|
| A normal page, form or app screen | Plain HTML and CSS |
| A few labels over a chart or map | HTML overlays positioned over the canvas |
| A static picture of some HTML, in every browser | html2canvas or a similar library |
| HTML inside an SVG | SVG foreignObject. Once you draw that SVG into a canvas, it's a static picture |
| HTML inside a canvas scene, under a shader or on a 3D surface, and still interactive | HTML-in-Canvas, with a fallback for Firefox and Safari |
Drawing a page through a canvas gives up some of what the browser does for plain HTML. That includes scrolling and animating off the main thread. That's Mozilla's main objection, covered below. Our rule: use HTML-in-Canvas where you'd have used a canvas anyway. Don't use it to rebuild a normal page inside one…
Your first HTML-in-Canvas page
Here's the smallest version that works in Chrome 157. The canvas opts in, the card is marked drawable, and the paint handler draws it:
<canvas id="canvas" width="600" height="300" content="drawable">
<div id="card" drawable>
<b>Hello</b> from HTML <input value="type here">
</div>
</canvas>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const card = document.getElementById("card");
canvas.onpaint = () => {
ctx.reset();
ctx.drawElementImage(card, 50, 40);
};
The input inside the card is a real input. Click it in the canvas and you can type…
Check for support before you rely on it:
function supportsHtmlInCanvas() {
const test = document.createElement("canvas");
test.setAttribute("content", "drawable");
return test.content === "drawable";
}
In Firefox and Safari, canvas.content is undefined, so the check returns false. But those browsers still support <canvas>, so they don't render anything inside it. Leave the card in the canvas and readers see an empty rectangle. Move it out and show it as normal HTML instead:
if (!supportsHtmlInCanvas()) {
canvas.after(card);
canvas.hidden = true;
}
Chrome's concepts page lists the other fallbacks: a different UI, HTML overlays, or a library like html2canvas.
Getting HTML-in-Canvas right
Size the canvas to device pixels, or it blurs. This is the most discussed question in the spec's issue tracker. Three units are in play: CSS pixels, device pixels and the canvas's own grid. Watch the canvas with a ResizeObserver on device-pixel-content-box. Then set canvas.width and canvas.height from devicePixelContentBoxSize. Setting either one clears the canvas, even to the same value, so do it only when the size changes. And give drawable elements box-sizing: border-box, because the border box is what gets drawn.
Don't erase what you didn't redraw. changedElements lets you redraw only what changed. But ctx.reset() clears the whole canvas. Reset and redraw only the changed elements, and everything else disappears… Either redraw everything, or clear only the areas you redraw.
Nested drawables are drawn on their own. A drawable element inside another one is left out of its parent's snapshot. Draw the parent alone and that child is missing, so give it its own drawElementImage() call.
Only drawable elements can be drawn. Calling drawElementImage() on any other child throws:
InvalidStateError: Failed to execute 'drawElementImage' on 'CanvasRenderingContext2D':
Descendants passed to DrawElementImage must have the 'drawable' attribute.
Tell the browser where things went in 3D. After you upload an element to a WebGL texture, call updateElementGeometry(). Pass a DOMMatrix that maps the element to where it appears on screen. Without it, clicks land on the element's old position, not the one on your mesh.
const gl = canvas.getContext("webgl2");
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// Allocate once. texElementSubImage2D fills existing storage.
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 256, 128, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
canvas.onpaint = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, card);
// ...draw your scene, then:
canvas.updateElementGeometry(card, { canvasTransform: transformForCard });
};
transformForCard is the DOMMatrix from your own scene math, in CSS pixels. In WebGPU the upload is device.queue.drawElementImageToTexture({ source: card }, { texture, size: { width, height } }). The geometry call is the same.
Workers get images, not elements. To draw in a worker, call canvas.captureElementImage(card) inside paint. It returns an ElementImage. Post it to a worker that owns an OffscreenCanvas, and call close() on it when you're done.
TypeScript types for the API are in DefinitelyTyped.
Library support
Every major engine added HTML-in-Canvas support during the origin trial, on the old API. Chrome 157 removed the old method names. So their current releases either draw a blank texture or throw. Status on November 5, 2026:
| Library | Its HTML-in-Canvas API | Latest release | Works on Chrome 157? |
|---|---|---|---|
| Three.js | HTMLTexture | r187 (October 30) | No. It only uploads when texElementImage2D exists, so on Chrome 157 it skips the upload and the texture stays blank, with no error. The fix, PR #34732, is open and planned for r188 |
| PixiJS | HTMLSource | 8.22.0 (October 1) | No. It throws "texElementImage2D is not available" (#12340). The fix is merged and not yet released |
| PlayCanvas | Texture.setSource() | 2.23.3 (October 15) | No. Textures render blank (#9820). The fix is merged and not yet released |
| Babylon.js | HtmlTexture | 9.34.0 (November 5) | Yes, from 9.33.0 (October 29). 9.32.0 and earlier use the old names |
Did an HTML texture go blank after the update? Or does the console say a texElement method doesn't exist? This is why… Upgrade where a fixed release exists. Until one does, call the new methods directly.
The full HTML-in-Canvas API
Here's what Chrome 157 ships, per MDN's compatibility data. We checked that every method below exists in Chrome 157.
| Part | What it does |
|---|---|
<canvas content="drawable"> | The canvas lays out its children instead of treating them as fallback. Reflected as canvas.content |
drawable attribute | Marks an element as something you can draw |
paint event, onpaint | Fires when a drawable element's layout, text or CSS changes, at most once per frame. Its CanvasPaintEvent has changedElements, which lists what changed |
canvas.requestPaint() | Asks for a paint event on the next frame |
ctx.drawElementImage(el, dx, dy) | Draws the element in 2D. Also takes a size, a source rectangle, and { preserveElementGeometry: true } to leave the element's position alone |
gl.texElementSubImage2D(target, level, x, y, el) | Uploads the element into existing WebGL texture storage |
queue.drawElementImageToTexture({ source, sourceX, sourceY, sourceWidth, sourceHeight }, { texture, size }) | Uploads the element into a WebGPU texture. The four source fields are optional, all or none |
canvas.updateElementGeometry(el, options) | Tells the browser where the element appears, for clicks and screen readers. Options: canvasTransform, canvasClip, preserveHitTestOrder |
canvas.clearElementGeometry(el) | Removes the element from hit testing and marks it offscreen for screen readers |
canvas.getElementTransform(el) | Returns the element's current transform as a DOMMatrix |
canvas.getElementImageDefaultSize(el) | The element's size in canvas pixels. Our 200px-wide card returned { width: 200, height: 71 } |
canvas.captureElementImage(el) | Returns an ElementImage you can send to a worker |
elementgeometryupdate event | Fires when geometry set from a worker reaches the page |
One gap between the docs and Chrome 157. MDN's draft pages describe an HTMLElement.drawable property, but in Chrome 157 it's undefined. Use the attribute.
What HTML-in-Canvas won't draw, and why
WebGL and WebGPU let JavaScript read pixels back. So anything a page isn't allowed to see would leak if the browser drew it. Instead of marking the canvas as tainted, the browser leaves that content out. According to the explainer, these aren't drawn:
- Images, iframes and CSS
url()backgrounds from another origin, and canvases already tainted by them - Visited-link styling, so a page can't learn your history
- Spelling and grammar underlines
- System colors and themes
- Autofill values the page can't otherwise read
- Video captions and controls
- Input method pop-ups
- Subpixel text anti-aliasing
Form controls, scrollbars and Find-in-page highlights are drawn, because a page could already learn how they look. A same-origin iframe is drawn too, minus anything cross-origin inside it.
HTML-in-Canvas and accessibility
The elements stay in the DOM. So a screen reader, Find, translation, autofill and browser extensions all work on them. aria-hidden and inert are respected. An element that hasn't been drawn yet is still reported to screen readers, just without a position.
Some of it is still on you. Focus rings and text carets show up only because they're part of the element you draw. Skip drawing a focused element, and its focus ring isn't on screen. In 3D, a missing updateElementGeometry() call sends clicks and screen magnifiers to the wrong place. And if a shader distorts an element, sighted users see the distortion. Screen readers still get the original text.
The W3C's Technical Architecture Group gave initial feedback in August on its review, which is still open. It said it was "very supportive of the goals", and that the proposal needs more work, including on accessibility.
Is HTML-in-Canvas good for the web?
When the proposal reached Hacker News, the top worries were fingerprinting, accessibility, and a return to Flash-era sites built entirely in canvas. Browser makers split along the same lines.
Mozilla hasn't taken a formal position. A Mozilla graphics engineer called the proposal "a step in the wrong direction" for some of its goals (standards-positions #1076). Mozilla's case: pages drawn through a canvas lose rendering the browser can do off the main thread, like smooth scrolling. They also hide their structure from the browser. And pixels that JavaScript can read open new ways to fingerprint users and detect ad blockers. Another Mozilla engineer called it a "pixel oracle". Pages will start depending on exactly how one browser renders a form or a scrollbar.
Google's answer: canvas-heavy apps already exist, and they already rebuild text and accessibility badly. This gives them the browser's own. The design takes one snapshot per frame, so pages can't force layout on demand or loop over rendering.
WebKit said on August 3 it would mark the proposal as support "pending objections". Discussion of the API's shape continues.
Our view: it's worth using for apps that were going to be canvas apps anyway. For anything that works as a normal page, it's a bad idea.
Browser support and standards status
| Browser | Support |
|---|---|
| Chrome | 157 and later, on by default |
| Edge | 157, listed in its release notes |
| Opera, Samsung Internet, Brave | Chromium-based, but not confirmed by their makers |
| Firefox | No, and no position yet |
| Safari | No. WebKit leans toward support |
On the standards side, the WHATWG proposal reached stage 3, and the spec change is open. The W3C TAG review is in progress, labelled as missing support from more than one browser. MDN's pages are in review, and the feature isn't Baseline. So feature-check, and keep the plain HTML working without it.
HTML-in-Canvas versus html2canvas
html2canvas reads the DOM and redraws it with canvas calls. So it's a copy of the browser's rendering, and some CSS doesn't come out right. HTML-in-Canvas draws the browser's own rendering. What you see in the canvas is what the page looks like, and it stays interactive. Neither one draws cross-origin images by default.
What decides it today is browser support. html2canvas works everywhere. HTML-in-Canvas works only in Chromium browsers. If you need an image of some HTML in every browser, keep html2canvas. If you're Chromium-only, like an Electron app or an internal tool, HTML-in-Canvas gives you the real rendering. The same goes if you can fall back for everyone else.
If your code uses layoutsubtree
Code from the origin trial looks right and fails in Chrome 157. These are the changes, from Chrome's list:
| Before | Chrome 157 | Changed in |
|---|---|---|
<canvas layoutsubtree> | <canvas content="drawable"> | Chrome 155 |
| Only direct children drawn | Any descendant with drawable | Chrome 155 |
drawElementImage() returned a DOMMatrix you applied to style.transform | Returns nothing; 2D moves the element itself | Chrome 155 |
texElementImage2D(target, level, internalFormat, format, type, el) | texElementImage2D(target, internalFormat, el) | Chrome 150 |
texElementImage2D | texElementSubImage2D, after allocating with texImage2D(..., null) | Chrome 155 |
copyElementImageToTexture(el, width, height, { texture }) | copyElementImageToTexture({ source }, { destination, width, height }) | Chrome 150 |
copyElementImageToTexture({ source, sx, sy, swidth, sheight }, { destination: { texture }, width, height }) | drawElementImageToTexture({ source, sourceX, sourceY, sourceWidth, sourceHeight }, { texture, size: { width, height } }) | Chrome 155 |
The errors you'll see in Chrome 157, from our run:
- A canvas with
layoutsubtree:DrawElementImage requires the canvas to have the content=drawable attribute. - A child without
drawable:Descendants passed to DrawElementImage must have the 'drawable' attribute. - The old
const t = ctx.drawElementImage(...); el.style.transform = t.toString()pattern:TypeError: Cannot read properties of undefined (reading 'toString'). Drop the return value and thestyle.transformline; the browser moves the element for you. - WebGL code calling
texElementImage2D: the method isundefined, so you get "is not a function".
Frequently asked questions
What is HTML-in-Canvas?
HTML-in-Canvas is a browser API that lets a <canvas> draw real HTML elements in 2D, WebGL or WebGPU. The elements stay in the page, so their text can be selected, searched and read by screen readers, and their inputs still work.
Which browsers support HTML-in-Canvas?
Chrome supports it from version 157, released on November 3, 2026, and Edge 157 lists it too. Firefox and Safari don't support it yet.
Does HTML-in-Canvas work in Firefox or Safari?
No. Mozilla hasn't taken a position and has raised performance and fingerprinting concerns. WebKit said in August 2026 that it would mark the proposal as support, but Safari doesn't ship it.
Is content drawn with HTML-in-Canvas accessible to screen readers?
Yes. The elements stay in the DOM, so screen readers read them, and in 2D their position follows where you draw them. In WebGL and WebGPU you have to call updateElementGeometry() so the position is right.
How is HTML-in-Canvas different from fillText()?
fillText() draws text as pixels, with no selection, Find or screen reader support, and you lay it out yourself. HTML-in-Canvas draws a real element that the browser lays out, so you get CSS, wrapping, inputs and accessibility.
Can HTML-in-Canvas replace html2canvas?
In Chromium browsers, yes, and it's more faithful, because it draws the browser's own rendering instead of a copy. In Firefox and Safari it doesn't exist, so keep html2canvas as the fallback if you need images there.
Why is my HTML texture blank in Chrome 157?
Your library or code uses the origin trial's API, which Chrome 157 removed. The current releases of Three.js and PlayCanvas skip the upload and leave the texture blank, and PixiJS throws an error. Babylon.js is fixed from 9.33.0. The library table has the details.
What replaced layoutsubtree?
content="drawable" on the canvas, plus a drawable attribute on each element you draw. The change came in Chrome 155.
Why does drawElementImage return undefined?
Since Chrome 155 it doesn't return a transform. In 2D the browser moves the element to where you drew it, so there's nothing for you to apply. To manage the position yourself, pass { preserveElementGeometry: true } and call updateElementGeometry().
Why is my canvas blurry?
The canvas grid doesn't match the screen's device pixels. Set canvas.width and canvas.height from a ResizeObserver watching device-pixel-content-box.
Can I draw cross-origin images or iframes into the canvas?
No. Cross-origin images, iframes and backgrounds are left out, because the page could read their pixels. Same-origin content is drawn.
Is HTML-in-Canvas a fingerprinting risk?
It's the main concern raised against it. The design leaves out anything a page couldn't already see, such as visited links, spelling marks and system colors. Mozilla argues that list can never be complete.
Isn't HTML-in-Canvas just Flash again?
Flash content was invisible to the browser. Elements drawn with HTML-in-Canvas stay real DOM, so screen readers, Find and extensions still see them. The worry that sites will move into canvas wholesale is fair, though, and it's why we'd use it only where a canvas was already the plan.
Can I use HTML-in-Canvas in a web worker?
Yes, with images. Call captureElementImage() in the paint event and post the ElementImage to a worker that owns an OffscreenCanvas. Elements themselves can't go to a worker.
- HTML
- canvas
- JavaScript
- Web Development
- Google Chrome
- WebGL
About the author

Syed Fazle Rahman
Co-founder, Bug0 · Building Buildful
At Bug0, Fazle builds AI agents that test web apps end to end. He previously co-founded Hashnode and helped grow it into one of the web's largest developer communities. A front-end and UX engineer by training, he wrote Jump Start Bootstrap and Jump Start Foundation for SitePoint and has spent more than a decade building products for developers.
Try it on your own repo
Describe a change. Buildful builds it, tests UI changes in a real browser, and opens a pull request. Your first 3 tasks are free, then 1,000 tasks for $10 a month. See pricing.
Start building