· 7 min read
The button in the painting
WebGPU reached baseline in January and Chrome is now trialing HTML-in-canvas, so this walkthrough writes a first WGSL shader and then draws a live, clickable DOM element into the same kind of canvas.

In January, WebGPU became Baseline: Chrome, Edge, Firefox, and Safari 26 all ship it, on by default. I have been writing shaders in other people's engines for years while treating the raw API as a Chrome hobby, and that excuse expired this winter.
So this weekend I ran the two experiments this post walks through. The first writes a WGSL shader against the bare API. The second tries the thing Chrome announced at I/O in May — an origin trial that lets real DOM elements ride into a canvas and stay alive there.
That second experiment is where the title comes from. By the end there is a button that exists only as paint, swaying inside a canvas, and clicking the paint increments its counter.
Ninety lines to the first pixel#
WebGPU's reputation is boilerplate, and the reputation is earned in volume but not in kind. The setup is a straight line with no mystery in it — ask for an adapter, ask it for a device, point the canvas at both:
const adapter = await navigator.gpu.requestAdapter();const device = await adapter.requestDevice();const ctx = canvas.getContext("webgpu");const format = navigator.gpu.getPreferredCanvasFormat();ctx.configure({ device, format, alphaMode: "premultiplied" });After that you compile a shader module, build a pipeline, and draw. My whole island — setup, render loop, pause button, visibility handling — is about ninety lines. None of them are the WebGL ritual of enums and global state, because the API hands you devices, pipelines, and encoders as plain objects.
Two habits carried over from graphics work anyway. A fullscreen "quad" is really one triangle with three oversized corners, and uniform structs pad to 16-byte alignment — which is why my three floats travel with a pad they never use.
The shader itself is the fun part, and WGSL reads like Rust that lost interest in ownership. Mine is four sine waves pushed through Íñigo Quílez's cosine palette trick, with the clock as the only input:
struct Uniforms { time: f32, width: f32, height: f32, pad: f32 };@group(0) @binding(0) var<uniform> u: Uniforms;@fragmentfn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f { let uv = pos.xy / vec2f(u.width, u.height); let p = vec2f(uv.x * (u.width / u.height), uv.y) * 6.0; var v = sin(p.x + u.time); v += sin(0.5 * (p.y + u.time)); v += sin(0.4 * (p.x + p.y + u.time)); v += sin(length(p - vec2f(3.0, 1.5)) + u.time * 1.2); let col = 0.5 + 0.5 * cos(6.2832 * (vec3f(v * 0.25) + vec3f(0.0, 0.33, 0.67))); return vec4f(col, 1.0);}And here is that exact shader, compiled by whatever GPU you brought:
One honest asterisk on "runs everywhere": your CI probably is not baseline. Getting this shader to render in headless Chromium for this article's tests took four command-line flags ending in SwiftShader, a software GPU. A reader gets the real hardware for free, and a build server still has to be talked into pretending it has any.
The document climbs into the frame#
Canvas UIs have always paid a tax. The moment your interface becomes pixels, everything the browser was quietly doing for your elements becomes your job, and the ledger is longer than it looks:
| UI as pixels | Canvas with layoutsubtree | |
|---|---|---|
| Hit testing | Yours — rectangle math against mouse coordinates | The browser's, computed from real layout |
| Focus and text selection | Rebuilt by hand, rarely completely | Native — the element never left the tree |
| What a screen reader finds | Nothing | The DOM node it expected |
| Your job each frame | Everything, because the pixels are the interface | One drawElementImage call to project it |
The HTML-in-canvas origin trial (Chrome 148 to 150, or the canvas-draw-element flag) is the right-hand column. Children of a canvas marked layoutsubtree get layout and hit testing like ordinary elements — they are simply invisible until you draw them:
<canvas id="c" width="640" height="360" layoutsubtree> <div id="card"> <p>A real, live DOM node.</p> <button id="b">clicked <span>0</span> times</button> </div></canvas>Then drawElementImage paints the element's live rendering wherever the canvas transform points, and returns the matrix that keeps the real element aligned with its painted pixels. Apply it, and clicks keep landing:
const paint = (ms) => { ctx.clearRect(0, 0, 640, 360); ctx.save(); ctx.translate(320, 180); ctx.rotate(Math.sin(ms / 900) * 0.15); ctx.translate(-126, -60); const transform = ctx.drawElementImage(card, 0, 0); card.style.transform = transform.toString(); ctx.restore(); requestAnimationFrame(paint);};Which produces this — live if your browser carries the flag, recorded if not:
Two things surprised me, in both directions. My first call threw No cached paint record for element: an element has to be painted once before it can be sampled, which is what the new paint event on the canvas is for. The demo above handles it by retrying until the record exists.
The other surprise ran the opposite way. Before I had drawn anything at all, with the card fully invisible, my test harness clicked the button and the counter moved — layout and hit testing arrive with the attribute, not with the drawing. An invisible button that works is a strange object, though I can see the logic of it.
The same trick extends past 2D. texElementImage2D uploads an element as a WebGL texture, and copyElementImageToTexture does it for WebGPU — meaning the plasma shader above could, in a flagged Chrome, warp a live form the way it warps sine waves. Cross-origin iframes are banned outright, which is the right paranoia: "draw any rendered pixel into a readable buffer" is a phishing kit if it works on content you do not own.
One surface, finally#
What I keep turning over is that these two APIs point at the same seam from opposite sides. WebGPU made the browser's fastest drawing surface a standard citizen, and HTML-in-canvas lets the document onto that surface without giving up being a document.
The accessibility story is the part I did not expect from a graphics API. The button in my demo stays in the tree, and my test harness finds it by ARIA role and clicks it — though I have not put an actual screen reader on it, so treat that half as promising rather than proven.
The usual caveat applies with extra force: this is an origin trial, the API already shed one name on the way here (early drafts called it placeElement), and it may ship changed or absent. I am not building on it; I am enjoying it.
Hoogstraten painted his letter rack so well that visitors reached for the comb, and that gap — between paint you want to touch and paint that answers — has been the whole difference between an image and an interface. In one flagged browser on my desk, for one swaying button, the gap is closed. My counter reads 14, and I put every one of those clicks on the paint.