Canvas
The WASM canvas demo, written where it actually lives: a script. The x markers cover state, bindings and structure; a canvas animation loop is a program, so it is the escape hatch. The WASM version is Canvas — the same scene, in Go.
Pointer x: 0 — move the pointer over the canvas.
Move the pointer over the canvas.
How it works
The canvas
<canvas data-x-canvas width="600" height="200"></canvas>
<p class="demo-note">Pointer x: <span>@x.Text(XPointerX)</span> — move the pointer over the canvas.</p>The canvas is plain markup. The one reactive bit is the pointer readout: an @x.Text marker over a named signal the script feeds.
The loop
templ xCanvasScript() {
<script>
const canvas = document.querySelector("[data-x-canvas]");
const ctx = canvas.getContext("2d");
const r = canvas.getBoundingClientRect();
canvas.width = Math.round(r.width);
canvas.height = Math.round(r.height);
const w = canvas.width;
const h = canvas.height;
let elapsed = 0;
let last = null;
let px = -9999;
canvas.addEventListener("pointermove", (e) => {
const rect = canvas.getBoundingClientRect();
px = e.clientX - rect.left;
pointerX.set(Math.round(px));
});
function frame(now) {
if (last !== null) elapsed += (now - last) / 1000;
last = now;
ctx.clearRect(0, 0, w, h);
for (let i = 0; i < w; i += 6) {
const wave = Math.sin(i / 40 + elapsed * 2) * 40;
const lift = Math.exp(-Math.pow(i - px, 2) / 4000) * 30;
ctx.fillStyle = "#4f7cff";
ctx.fillRect(i, h / 2 + wave - lift, 3, 3);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
}The whole animation is a script: the same wave the WASM demo draws, with the pointer lifting the line. The script escape hatch is the boundary of the marker set — and for canvas, it is the whole answer.
Full source
templ XCanvasDemo() {
<div class="demo">
<canvas data-x-canvas width="600" height="200"></canvas>
<p class="demo-note">Pointer x: <span>@x.Text(XPointerX)</span> — move the pointer over the canvas.</p>
@xCanvasScript()
</div>
}
templ xCanvasScript() {
<script>
const canvas = document.querySelector("[data-x-canvas]");
const ctx = canvas.getContext("2d");
const r = canvas.getBoundingClientRect();
canvas.width = Math.round(r.width);
canvas.height = Math.round(r.height);
const w = canvas.width;
const h = canvas.height;
let elapsed = 0;
let last = null;
let px = -9999;
canvas.addEventListener("pointermove", (e) => {
const rect = canvas.getBoundingClientRect();
px = e.clientX - rect.left;
pointerX.set(Math.round(px));
});
function frame(now) {
if (last !== null) elapsed += (now - last) / 1000;
last = now;
ctx.clearRect(0, 0, w, h);
for (let i = 0; i < w; i += 6) {
const wave = Math.sin(i / 40 + elapsed * 2) * 40;
const lift = Math.exp(-Math.pow(i - px, 2) / 4000) * 30;
ctx.fillStyle = "#4f7cff";
ctx.fillRect(i, h / 2 + wave - lift, 3, 3);
}
requestAnimationFrame(frame);
}
</script>
}
// On the page: @x.Document(XCanvasDemo())When to reach for the escape hatch
Markers pay off when state drives markup. A canvas redraws every frame by nature — there is no declarative core to bind to — so the script isn't a workaround here, it is the answer. The WASM version exists for when you want that logic in Go instead.