HTML in Canvas
There’s a new WICG proposal that lets you render real, interactive HTML elements into a <canvas>. including WebGL and WebGPU contexts. I spent a few days building demos with it and two of them went viral. Here’s what I learned about what the API uniquely enables. and where existing approaches fall short.
chrome://flags/#canvas-draw-element. Without it, you’ll see empty dark rectangles where the demos should be. The Pitch
The API has three primitives: a layoutsubtree attribute that opts canvas children into layout, a texElementImage2D() method that uploads an element’s rendering as a WebGL texture, and a paint event that fires when children change. The basic loop:
canvas.onpaint = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texElementImage2D(
gl.TEXTURE_2D, 0, gl.RGBA,
gl.RGBA, gl.UNSIGNED_BYTE, element
);
};
Your HTML is now a texture. A fragment shader can read every pixel, distort them, blend them with other textures, map them onto geometry. And the original DOM is still interactive. inputs work, focus works, hit testing works.
What About a Canvas Behind Elements?
A fair question that came up: can’t you already position a <canvas> behind your HTML and draw glow effects? Yes. and for simpler effects, that works well. But there are a few things that approach can’t do.
A background canvas can draw. It can’t read.
The fundamental capability of texElementImage2D is that the shader has access to the rendered pixels of the HTML. A canvas behind or on top of elements is blind to what the HTML actually looks like.
This matters the moment you want to:
Distort content per-pixel. CSS transform operates on the entire element box. you can rotate, scale, skew a <div>. You cannot barrel-distort the rendered text inside it. You cannot compress the bottom half of an input while leaving the top half untouched. A shader reading the HTML texture can do both, because it samples the rendered pixels at arbitrary UV coordinates.
Blend two HTML states through custom math. Render both your light and dark themes as textures simultaneously. Now a fragment shader can blend between them pixel-by-pixel using noise, fire, scanlines, or any function you can write in GLSL. View Transitions give you two snapshots and CSS animations between them. This gives you two live renders and a shader.
React to rendered content. A shader can read a pixel’s luminance and make decisions. warm dark pixels differently than light ones, detect edges in the rendered HTML, apply effects that follow the actual shape of the content rather than the bounding box.
CSS and SVG filters cover a lot of ground, but they’re per-element and limited to a fixed set of operations. A fragment shader lets you write arbitrary per-pixel logic that can respond to cursor position, blend multiple elements, or follow the shape of the rendered content.
Demo 1: The Focus Ring
I built a Cloudflare Workers form with a shader-driven focus ring. When you tab between fields, a warm orange glow springs from element to element. The dot grid background illuminates near the focused field.
Open full screen ↗Why it needs the API
The glow itself could be done with a canvas behind the form. But three things can’t:
The dot grid and glow share a render pass. The background dots are drawn by the shader, not by CSS. When the focus ring moves, the dots near it warm up in the same fragment shader invocation. A background canvas could draw dots OR read focus position, but it can’t make the dot brightness respond to the SDF distance of the focus ring. that requires both to exist in the same rendering context.
The glow composites correctly with the form. The shader renders the glow onto the background, then composites the form texture on top with mix(color, form.rgb, form.a). This means the glow appears behind the form content but in front of the dot grid. With a background canvas, you’d either have glow behind everything (including the dots) or in front of everything (covering the form). Getting glow between two layers of the same visual surface requires the shader to control the full composite.
The scanline transition distorts the HTML. When you click Deploy, a scanline wipes down the form and physically compresses the content beneath it. The form text, inputs, and labels all squish and wobble as the line passes. the shader reads the form texture and samples it at displaced UV coordinates. A canvas behind elements cannot warp what’s on top of it.
Open full screen ↗How it works
The focus ring is three techniques composed:
Signed Distance Field. A function that returns, for any pixel, the distance to the nearest edge of a rounded rectangle. Negative inside, zero at the edge, positive outside. This one number drives the glow. pixels near zero glow bright, far pixels don’t.
float sdRoundBox(vec2 p, vec2 halfSize, float radius) {
vec2 q = abs(p) - halfSize + radius;
return length(max(q, 0.0))
+ min(max(q.x, q.y), 0.0) - radius;
}
Spring physics. On the JavaScript side, the SDF’s position and size are animated with spring dynamics. velocity += (target - current) * 0.18; velocity *= 0.65;. so the glow travels between fields with physical weight. CSS transitions use easing curves. Springs have overshoot and settle.
Dot grid illumination. Each dot is a distance check against a regular grid. The dot’s brightness is modulated by proximity to both the cursor and the focus ring’s SDF. Two influences, one dot, one shader.
Demo 2: Magnetic Cursor
Move your cursor over the text. The rendered HTML pixels physically attract toward the cursor with a smooth Gaussian falloff. Hold click to repel instead. The shader also splits the RGB channels at the distortion site, creating chromatic aberration.
Open full screen ↗This is per-pixel displacement of the rendered HTML. CSS transforms operate on element boxes, not individual pixels. The smooth sub-character warping and chromatic aberration at the distortion boundary require reading and resampling the rendered texture.
Demo 3: The Burn Transition
A dark mode toggle where fire consumes the page. Click anywhere and a burn front spreads from the click point. FBM noise drives the organic edge, embers glow at the boundary, smoke drifts ahead of the fire. Both light and dark themes are live HTML rendered as textures and blended per-pixel.
Why it needs the API
This is the demo that’s hardest to dismiss. Two complete HTML pages exist simultaneously as canvas children. The shader reads both textures every frame and composites them through five distinct zones:
- Heat distortion. the “from” texture’s pixels are displaced with noise-driven UV warping
- Ember line. new pixels sampled from both textures, blended with fire colors
- Char zone. the “from” texture darkened to near-black
- Clean reveal. the “to” texture, unmodified
- Smoke. FBM noise clouds composited over the “from” texture ahead of the fire
A canvas behind elements can’t do any of this. View Transitions can animate between two snapshots with clip-paths and opacity. This animates between two live renders with a five-zone fire simulation that reads and warps the actual rendered pixels.
<canvas layoutsubtree>
<div id="lightPage">...</div>
<div id="darkPage">...</div>
</canvas>
canvas.onpaint = () => {
gl.texElementImage2D(/*...*/, lightPage);
gl.texElementImage2D(/*...*/, darkPage);
};
Two textures, one shader, arbitrary blending. That’s the core value proposition of the API. Click anywhere to toggle:
Open full screen ↗What I Got Wrong
Most of my demos flopped. Liquid chrome focus rings, particle dissolution, breathing UI, cursor wake trails. technically interesting, visually flat. The ones that worked enhanced mundane interactions (focus states, dark mode toggles) in ways that felt obviously better than the status quo.
The API is most compelling not when it enables new categories of effect, but when it makes something everyone’s seen a thousand times feel dramatically better. The web has always been flat. This API lets it have depth. but only when that depth serves a purpose.
Practical Notes
A few things I learned the hard way:
UV mapping must flip Y. The texture is top-down, WebGL is bottom-up: v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5).
Canvas children must match canvas CSS size. A mismatch stretches the texture and all position calculations drift. worse the further down the page.
Wait for onpaint. Calling texElementImage2D before the first paint throws InvalidStateError.
DPR-aware coordinates. Scale CSS positions by canvas.width / canvasRect.width or everything drifts on Retina displays.
Solid backgrounds. Semi-transparent input backgrounds let shader effects bleed through. Use opaque colors.
It’s behind a flag. Chrome Canary, chrome://flags/#canvas-draw-element. Dev trial, not production.
What’s Next
The spec authors list three benefits: readbacks (sending to VideoEncoder), showing HTML on non-planar shapes, and using shaders on HTML. I’d add a fourth: compositing multiple HTML renders through custom math. The two-texture pattern. render two states, blend with a shader. is the most powerful thing I found. It’s a new primitive that doesn’t have a CSS equivalent.
Read the full proposal or try the demos in Chrome Canary.