astwerk

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

templ
<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
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
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.