Inspiration
There were a tonne of cool demos recently using the new HTML-in-Canvas API. They gave me the idea to recreate a freeware game from the 90s / early 2000s that I remember playing as a kid: Desktop Destroyer. I rebuilt it as Desktop Destroyer, reborn, playable at the end of this post. This one smashes the actual page.
Basics
There's traditionally a hard split between the structured, semantic side of HTML and the raw pixel control of canvas. Highly interactive sites lean on canvas for 3D scenes, games, and shader effects, but everything drawn there gives up what makes HTML useful: real inputs, focus, accessibility.
HTML-in-Canvas addresses this by letting you place HTML directly inside the <canvas> element, then draw that DOM content into the canvas. The whole API boils down to three new primitives:
layoutsubtree, an attribute that makes the canvas lay out its child elements (they stay invisible until drawn).ctx.drawElementImage(el, x, y), which paints a live child into the canvas and returns aDOMMatrix.- The
paintevent andcanvas.requestPaint(), the canvas cousin ofrequestAnimationFrame.
Amit Sheen's write-up covers these basics in more depth. Here's the quick version, and then we get to the smashing.
The starting HTML
Everything below starts from a small form. Nothing fancy, but a form makes a good test subject because it only counts as working if you can still type into it:
Move it inside the canvas
Next, the same markup goes inside a <canvas>:
The layoutsubtree attribute is the switch that makes all of this work. Without it the browser never lays out the canvas's children, so there's nothing to capture; with it, the children get layout and stay in the hit-testing tree, which is what keeps them clickable later.
One gotcha worth knowing early: drawElementImage only captures direct children of the canvas. Nest an element in a wrapper and drawing it silently snapshots empty; there's no error to tip you off. Anything inside the element you draw renders fine. It's only the element you pass that has to sit directly under the canvas. Those direct children also don't flow against each other like normal siblings. They pile up at the canvas origin, so for multi-element scenes you position each one yourself when you draw it (the matrix sync we'll meet later carries that layout back to the real elements).
At this point the form disappears. The elements are still in the DOM and still interactive (you can tab to the input and type into it), but nothing shows until we draw it in the next step.
Draw it on paint
Grab the three things the drawing code needs: the canvas, a 2D context, and the element to draw:
The API adds a new paint event to the canvas, and we can ask the browser to fire it:
So we listen for paint, do our drawing inside the listener, and request the first paint:
ctx.drawElementImage(content, 0, 0) is the heart of the API: it grabs what the element looks like on screen and paints it into the canvas wherever you say. Not a snapshot from some point in the past. Whatever the element looks like right now, at this paint.
The ctx.reset() before the draw wipes the context back to its defaults. That matters more than it looks: once transforms enter the picture (and they're about to), a stray transform left over from the last paint would compound into the next one.
With that, the form is visible again, this time as pixels on a canvas.
The result
Here's the whole thing running. Type into the input and hit the button; the canvas redraws the same live element every frame:
Two things worth calling out:
- CSS still handles the layout, and the content is re-captured every frame while staying interactive. This is not a dead
html2canvasPNG. - The canvas isn't tainted. The capture is privacy-scrubbed instead (cross-origin content blanks out), so
getImageDataand compositing still work on it.
Keeping it clickable
So far we've drawn the content exactly where the browser laid it out, which is why clicking still lines up. The fun starts when we transform the drawing, and that's where there's a catch: CSS transforms on the source element are ignored for drawing, but the element's hit area stays wherever layout put it. Rotate the pixels with a canvas transform and the form visually moves while the real input stays behind. You'd be clicking on empty pixels.
This is why drawElementImage returns a DOMMatrix. Assign it back to the element's transform and the real DOM (inputs, buttons, focus) lines up with the pixels you drew:
To prove it, the form below drifts around the canvas like the old DVD screensaver, and thanks to the transform sync you can click into it and type mid-flight. (Amit's write-up covers this transforms-vs-hit-testing catch too; it's where I first ran into it.)
Smash effect: the old way
Traditionally, to achieve this effect you'd use something like html2canvas to capture a static snapshot of the DOM and then draw it into a canvas. From there the smash is plain canvas work: snapshot what's currently on screen, chop that bitmap into a grid of tiles, then redraw each tile every frame with its own velocity, a bit of gravity, and some spin. It works well, but once captured the content is frozen. You can't interact with it any more.
Worth noting before we upgrade it: the HTML-in-Canvas version works basically the same way. The tiles, the physics, and the redraw loop are all identical. The difference is where the pixels come from (a live drawElementImage capture instead of a one-off snapshot), plus a few extra steps to keep the real element in sync.
Smash effect: HTML-in-Canvas
Type into the note, then hit smash. Each shard re-samples the live capture every paint, so the debris stays alive: the video keeps playing as its shard tumbles, and the text breaks apart letter by letter. The input is the party trick. It's drawn as its own live element with the same DOMMatrix sync from earlier, applied per paint as it flies, so it lands in the rubble still focused. Keep typing.
One caveat: Chromium doesn't rasterise <video> frames through drawElementImage yet, so each paint composites the frame in with a plain drawImage. The clip is fetched with CORS (crossorigin), which keeps the canvas untainted so the shards can keep sampling it.
Destruction effects
I want to target text elements and apply custom effects like burning or electrifying them. The effects themselves are plain 2D canvas work: pixel manipulation and particles, no new API needed for the flames. What HTML-in-Canvas adds is the element staying live underneath. drawElementImage re-captures the real text every frame, so the word stays editable while it burns.
The pipeline: read the capture back as an alpha mask and keep the ink pixels that border a transparent one, which gives you the glyph outlines. Bucket those points per character (a Range per glyph) and any effect can ride the real letterforms. Type into the word and the effect follows; flip "show outline" to see the points the effects ride. The source is split across the tabs:
The <input> below the word burns too, though only its text: the field is borderless, and its underline is drawn straight on the canvas, outside the mask. An input's value isn't a text node you can measure with a Range, so the whole field joins the mask as one big "letter". Type into it and the flames follow. The button is in on it as well. It burns by default, and because it's a real, live button drawn through the canvas, you can press and hold it to smother everything in smoke. Let go and it all reignites.
Putting it together
Everything above (the mask pass, per-glyph outlines, DOMMatrix sync, the smash, the fire and electric effects) is combined into one engine with the full weapon rack, wrecking three different UIs: an early-2000s Windows desktop, a live McTubey page, and the McTooly hardware store.
Further reading
- Desktop Destroyer (Windows XP) and McTubey: the full toy, just above.
- Amit Sheen: The Web Is Fun Again: the intro this post's basics follow, plus the pixel-manipulation and shader paths.
- Wes Bos's Duck Hunt Todo: where this started.
- Matt Rothenberg: HTML in Canvas: the WebGL/shader deep-dive.
- WICG/html-in-canvas: the proposal and live demos.