Skip to content
Development

Draw real HTML into a canvas, then drop the whole page under water

Until now, a pixel effect on a web interface meant a choice: keep the real HTML and make do with CSS, or redraw everything in a canvas and lose the inputs, text selection and screen readers. HTML-in-Canvas, an experimental Chrome API, removes that choice: the canvas draws its own child elements, and the browser still routes clicks and key presses from the drawing back to the right HTML element.

Before the examples. The API is only in desktop Chrome for now and is still experimental. To see examples 1 to 3 in full, open chrome://flags/#enable-experimental-web-platform-features, turn on Experimental Web Platform features and restart Chrome. Without it, example 1 shows a plain form, and examples 2 and 3 run their effect over a fake page drawn with fillText. Example 4 runs in every browser.

Step 1: Put the page inside the canvas

Mark the canvas with the layoutsubtree attribute (plus content="drawable"), put the HTML inside it, then draw it in the canvas's paint event with drawElementImage. The event runs again whenever the content inside changes, for example when you type.

<canvas id="stage" layoutsubtree content="drawable">
  <form id="card" drawable>
    <input placeholder="Your name">
    <button>Hi</button>
  </form>
</canvas>

<script>
  const ctx = stage.getContext("2d");
  stage.onpaint = () => {
    ctx.reset();
    ctx.drawElementImage(card, 0, 0);
  };
</script>

The example below draws the form twice: once flipped, cut into thin strips shifted along a wave to make a reflection in the water, and once for real. Type in the input and the reflection updates letter by letter.

Example 1. A real form in a canvas, with a rippling reflection.

One detail worth knowing: the browser uses the last drawElementImage call to decide where a click lands. So the real copy has to be drawn last. Draw the reflection last and clicks land on the reflection.

Step 2: Bend the pixels

Copy the 2D canvas into a WebGL texture after every paint, then in the shader read each pixel from a slightly wavy position. The WebGL layer sits on top with pointer-events: none, so clicks pass through to the real form. Drag the “Ripple” slider to change the amplitude.

// copy the 2D canvas into a WebGL texture after every paint
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, stage);

// fragment shader: read each pixel from a wavy position
vec2 q = uv;
q.x += sin(uv.y * 18. + time) * .012 * wobble;
q.y += sin(uv.x * 13. + time) * .01 * wobble;
gl_FragColor = texture2D(page, q);
Example 2. A shader ripples the page. The form underneath still takes typing and clicks.

Step 3: Make the water respond

Put a grid of water heights under the page. The pointer drops small pebbles into the grid. Every frame, each cell becomes the average of its neighbours minus its own value from the step before, times a factor below 1: waves spread out and die away. In the shader, the slope of the water bends the page. The “Toss a coin” button still works under the water, and each press starts a ring of waves right at the button.

// each cell = average of its neighbours minus itself one step before
next[i] = (left + right + up + down) / 2 - prev[i];
next[i] *= 0.975;

// in the shader, the slope of the water bends the page
vec2 slope = vec2(right - left, up - down);
gl_FragColor = texture2D(page, uv + slope * .18);
Example 3. Water that responds to the pointer, with the odd raindrop.

Step 4: Let the text flow aside

This step needs no canvas API. The creature is a chain of circles along a spine. Every frame, each line of text works out which gaps the circles leave, then fills them with as many words as fit. The rest flows on to the next line.

for (let y = top; y + lineHeight <= bottom; y += lineHeight) {
  let free = [[left, right]];
  for (const c of body) {               // the circles along the spine
    const mid = y + lineHeight / 2;
    const dy = Math.max(0, Math.abs(c.y - mid) - lineHeight / 2);
    if (dy >= c.r) continue;
    const hw = Math.sqrt(c.r * c.r - dy * dy);
    free = cut(free, c.x - hw, c.x + hw);
  }
  for (const [a, b] of free) placeWordsThatFit(a, b, y);
}
Example 4. Text flowing around a creature made of circles.

Going further: burn the page

On the same principle, you can put a grid under the page that holds how burnt each point is. A click lights a fire at one point, and a glowing edge creeps outwards until it cools. In the shader, the burn value decides which layer a pixel belongs to: browned paper, charred black, a glowing ember rim, then a hole. After a while the page heals itself, and the form stays usable the whole time.

Taking it to production

  • So users don't have to turn on a flag, register your domain for Chrome Origin Trials, then add the token as a <meta http-equiv="origin-trial"> tag or an Origin-Trial header. The trial ends on 1 March 2027.
  • Always check "drawElementImage" in CanvasRenderingContext2D.prototype. Where it is missing (Safari, Firefox, phones), show the plain HTML page.
  • Clicks and key presses still work because the browser maps them from the drawing back to the real HTML. Screen readers and keyboards also work with the real HTML.
  • Respect prefers-reduced-motion. The examples here then turn off the waves and the rain and keep the creature still.
  • Since it only runs in desktop Chrome for now, record a video if you want to show the effect on social media.