Google announced a new Chrome API at I/O 2026. It lets you render real DOM elements inside a canvas. The DOM keeps working. Text stays selectable. Inputs accept focus. Ctrl-F finds words. Screen readers read it. Then a shader can bend the pixels however you want.
I had to read it twice. So I sat down with Claude to see what it actually does.
What the API Actually Is
Three new pieces. All behind chrome://flags/#canvas-draw-element in Chrome Canary or Brave on Chromium 147+.
layoutsubtree on <canvas>. Children lay out and receive events but don’t paint to the screen. They exist in the DOM. They take focus. They handle clicks. You decide where they appear visually.
Drawing methods. ctx.drawElement(...) for 2D canvas, gl.texElementImage2D(...) for WebGL2, device.copyElementImageToTexture(...) for WebGPU. Each one rasterizes the element and hands you the pixels.
A paint event on the canvas. It fires when a child element’s rendering changes. Typing a character. Hovering a button. Focus moving. The event includes a changedElements array so you can re-upload only the subtree that changed.
You’re not screenshotting the DOM. The DOM is still there. Still authoritative. Still reachable by document.querySelector. The canvas is just where the pixels land after a transform.
Experiment 1: A Login Form Behind Glass
We started with a login form. Put it behind a shader effect that would normally force you to give up interactivity.
Shader-on-canvas is familiar. The 3D LED marquee orb used the same WebGL setup. What’s new is the texture source. Real DOM instead of synthetic pixels.
<canvas id="stage" width="960" height="1120" layoutsubtree>
<form id="login">
<label><span>Email</span><input type="email" /></label>
<label><span>Password</span><input type="password" /></label>
<button type="submit">Continue</button>
</form>
</canvas>The form lives inside <canvas layoutsubtree>. CSS gives it a fixed size and styles it like any panel. The canvas has pointer-events: none so clicks and keystrokes pass through. The form has pointer-events: auto so it catches them.
The JS setup is mostly normal WebGL2. The new line is the texture upload.
gl.texElementImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE,
formElement
);One call rasterizes the form into the bound texture. I listen for the paint event to know when to re-upload.
canvas.addEventListener('paint', (e) => {
for (const el of e.changedElements) {
if (el === form || form.contains(el)) uploadElement(form);
}
});Typing into an input fires paint. So does hovering the button. So does focus moving. The re-upload happens only on visual change. The shader animation runs on requestAnimationFrame without re-rasterizing the DOM every frame.
The first shader was a sine-wave ripple. It worked. It looked like a 90s screensaver. A height field felt closer to glass.
float heightAt(vec2 uv) {
float h = 0.0;
if (u_intensity > 0.001) {
vec2 d = uv - u_mouse;
h += exp(-dot(d, d) * 28.0) * u_intensity * (0.55 + 1.6 * u_pulse);
}
// Slow ambient drift. The glass is "breathing".
h += 0.045 * sin(uv.x * 4.2 + u_time * 0.32) * cos(uv.y * 3.6 - u_time * 0.26);
h += 0.022 * sin((uv.x + uv.y) * 7.5 - u_time * 0.55);
return h;
}The fragment shader takes central differences of heightAt to get a 2D gradient. That becomes a surface normal. The normal drives both refraction and a Phong specular term.
vec3 N = normalize(vec3(-grad.x, -grad.y, 1.0));
vec3 L = normalize(vec3(-0.45, -0.6, 0.85));
float ndotl = max(0.0, dot(N, L));
float spec = pow(ndotl, 48.0);
col += vec3(0.85, 0.92, 1.0) * spec * (0.55 + 0.6 * u_intensity);A separate Fresnel term reads the texture’s alpha gradient at the panel’s rounded corners. It rims them with a soft blue-white glow. Chromatic aberration only fires during a click pulse, scaled by the local gradient strength.
The form bulges and catches light under the cursor. It breathes when nothing is touching it. Clicks produce a chromatic shimmer. The email input still focuses. The password still masks. The caret still blinks in the right place. Tab order works. A screen reader sees a normal form.
First time I typed “will” into the email field, the letters appeared inside a warping pane of glass. That was when the API clicked.
Experiment 2: Redactions That Lift Under a UV Lamp
The login form was a one-screen demo. What kind of game does this API unlock? I kept landing on investigation games. Reading documents and clicking words as the gameplay loop.
So we tried it. A 1974 case file with names, locations, and license plates blacked out. The cursor is a UV lamp. Drag it across a redaction and the ink lifts. The reveal fades after a few seconds.
There are two copies of the document inside the same canvas.
<canvas layoutsubtree>
<article class="doc clean" id="docClean" aria-hidden="true">
<p>I met <span>Eleanor Vance</span> at the <span>Brookline Hotel</span> ...</p>
</article>
<article class="doc redacted" id="docRedacted">
<p>I met <span class="ink">Eleanor Vance</span> at the <span class="ink">Brookline Hotel</span> ...</p>
</article>
</canvas>Both articles are position: absolute; inset: 0. They stack at the same coordinates. The .ink spans on the redacted version paint same-color text on same-color background. The words are invisible. The layout is preserved. The clean copy is the same text with no ink spans.
Two texElementImage2D calls produce two textures. Identical layout. Different visible content.
The wipe is a list of decaying stamps. The cursor drops one each frame it moves. Each stamp lives about 3.5 seconds. The shader sums their Gaussian falloffs into a single mask.
float wipeAt(vec2 uv) {
float w = 0.0;
for (int i = 0; i < MAX_STAMPS; i++) {
if (i >= u_stampCount) break;
vec3 s = u_stamps[i];
vec2 d = uv - s.xy;
w += exp(-dot(d, d) * 280.0) * s.z * 2.2;
}
return clamp(w, 0.0, 1.0);
}
// Composite
float wipe = smoothstep(0.08, 0.45, wipeAt(uv) + (noise - 0.5) * 0.08);
vec3 col = mix(redactedTex.rgb, cleanTex.rgb, wipe);Where the wipe mask is strong, the redacted texture lerps to the clean one. A cyan halo around the cursor sells the UV-light fiction. A cool tint on the revealed text makes the light feel like it’s doing the work.
Gameplay falls out for free. Wipes decay, so the player only sees a couple redactions at a time. To remember anything, they have to write it down. A notebook panel would be the obvious next step. Another piece of real HTML inside the canvas. Click a revealed word, it logs to an editable list you can search and copy from. Different documents could want different lights. Cyan UV here. Infrared on the next. Magnetic ink on the third. Examining the evidence becomes navigating real HTML through different shaders.
I did not build the notebook. The prototype was enough to feel the shape of the game.
Things That Went Wrong
No cached paint record for element. The redaction prototype crashed on load with this error from texElementImage2D. The form prototype worked synchronously, so I expected the same thing here. It didn’t.
The browser caches a paint record for a layoutsubtree child only after the element has been rasterized at least once. The form prototype got lucky with timing. The redaction prototype has two stacked position: absolute children. The initial upload ran before either was ready.
The fix is to retry on requestAnimationFrame until the upload succeeds. Gate the render loop on a ready flag.
function tryUpload(tex, el, key) {
try {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el);
ready[key] = true;
return true;
} catch (err) {
if (String(err.message).includes('No cached paint record')) return false;
throw err;
}
}
function pump() {
if (!ready.redacted) tryUpload(texRedacted, docRedacted, 'redacted');
if (!ready.clean) tryUpload(texClean, docClean, 'clean');
if (!ready.redacted || !ready.clean) requestAnimationFrame(pump);
}
requestAnimationFrame(pump);This is probably the most common gotcha. The DOM isn’t always paintable on demand. Treat the first successful upload as an async event.
Containing block for absolutely positioned children. The two articles use position: absolute; inset: 0 so they overlap. Without a positioned ancestor, absolute resolves against the viewport instead of the canvas. The texture upload came out oddly sized. Adding position: relative to the canvas fixed it. layoutsubtree does not establish a containing block. Normal CSS rules still apply.
Wipe spots too small to notice. My first stamp radius was about 32 device pixels. I assumed that would be obvious. It wasn’t. Bumping the Gaussian coefficient down from 900 to 280 made the wipes feel like a flashlight beam instead of a pinprick. Tune shader effects against the actual content underneath.
Working With Claude
Claude was in the loop the whole time. Honestly, I would not have gotten this far alone. Not in an evening.
I described what I wanted in plain English. “Liquid glass with a soft cursor wake.” Claude wrote the shader. I tuned constants by eye. Asked for changes when the feel was off. Switching from a sine-wave ripple to a height-field version took about two minutes. Hours by hand.
The paint record race condition would have stopped me too. I didn’t know what the error meant. Claude proposed the retry-on-rAF pattern. It worked in both prototypes.
I came in wanting to understand what the API does. With Claude I poked at more of it, faster. That was the point.
Where This Goes
The API is in origin trial. Only behind a flag on Chromium 147+. Safari and Firefox haven’t signaled implementation. The spec lives at WICG, so it’s pre-standardization. Anything built against the trial is a prototype. Not a shippable feature without a fallback. The trial exists to find the issues I just found.
There are also security questions about pulling DOM content into shader space. Cross-origin iframes inside layoutsubtree are blocked. The broader fingerprinting and side-channel surface is still being explored. The committee will probably make this more restrictive before more permissive.
I think there’s a lot of potential here.
Investigation games, like the prototype above. The genre has always been awkward to build. Either your text is real and the visuals are bounded by CSS, or your visuals are unbounded and your text is a screenshot. This API erases that tradeoff.
Spatial UIs in the browser that don’t have to choose between looking like an app and being accessible. A floating panel in a WebGPU scene can be a real <form>. Not a textured quad with an invisible event handler glued behind it.
Document viewers with effects like page flip, magnifying glass, or ink bleed. The underlying text remains searchable. This used to require a big compromise on either visuals or text behavior.
Education content where you can interact with the thing you’re learning. A chemistry diagram with labels in a screen-reader-friendly <dl> while the molecule rotates in 3D.
I thought this one was interesting, so I made it quickly.
Things it probably isn’t for: action games. Anything fast-paced. Anything that doesn’t need real DOM inside its visuals. The rasterization is not free. The closer your idea is to “reading and clicking is the gameplay,” the better the fit.
Trying the API Yourself
I haven’t published the prototypes. They are local experiments on my machine. To try the API yourself, paste this prompt into Claude or Cursor and tell it what you want to build. It distills what I learned so you don’t have to relearn it.
I want to build a prototype with Chrome's HTML-in-Canvas origin trial.
This API renders real DOM into a <canvas> via gl.texElementImage2D
(WebGL2), ctx.drawElement (2D), or device.copyElementImageToTexture
(WebGPU). The DOM stays interactive and accessible.
Browser setup:
- Chromium 147+ with chrome://flags/#canvas-draw-element enabled.
Core pattern:
- Source HTML lives inside <canvas layoutsubtree>. Children lay out and
receive events but do not paint to the page.
- Listen for the canvas "paint" event to know when DOM content changed.
Re-upload the texture only then, not every animation frame.
Gotchas worth knowing up front:
1. The first call to texElementImage2D or drawElement often throws
"No cached paint record for element." Wrap it in a retry on
requestAnimationFrame until it succeeds. Gate the render loop on a
ready flag.
2. If layoutsubtree children use position: absolute, give the <canvas>
position: relative so they resolve against the canvas, not the
viewport.
3. Canvas should have pointer-events: none. Children need
pointer-events: auto so clicks and keys reach the DOM.
4. On a 2D canvas, drawElement rasterizes the source at device pixels.
Do not also apply ctx.setTransform(dpr, ...). Pick one or labels
render 4x too big.
5. The API name may differ between builds. Check for ctx.drawElement
first and ctx.drawElementImage as a fallback.
What I want to build: [DESCRIBE YOUR IDEA]
Pick the right API surface (2D drawElement vs WebGL2 texElementImage2D
vs WebGPU copyElementImageToTexture). Scaffold the files. Walk me
through the shader if one applies.To run anything against the origin trial: Chrome Canary or Brave on Chromium 147+. Enable chrome://flags/#canvas-draw-element and relaunch. The official intro lives on the Chrome for Developers blog and the spec is at WICG/html-in-canvas.
Try this first. Put a focused input behind a shader. Type into it. Watch the caret blink in the right place. Hit Tab. Every browser affordance you take for granted still works under whatever distortion you applied.
This was a learning project. I wanted to know what the API does. Now I do. It was fun. I’m excited to see where people take it.
Cheers,
Will