Svelte, Astro and Solid
Draw a WebGPU shader without React. createShaderRenderer is a plain function, so Svelte, Astro, Solid and vanilla JavaScript mount one the same way.
React is a binding, not the foundation. react and react-dom are optional
peer dependencies, and the drawing happens in createShaderRenderer — a plain
function that takes a canvas and gives back a handle. Anything that can hand
you a mounted <canvas> and tell you when it goes away can draw a shader.
Every framework does the same three things:
- Give the renderer a mounted
<canvas>. - Call
setParamswhen your state changes. It never recompiles a pipeline. - Call
destroy()on teardown, or the surface leaks.
Svelte
onMount may return a cleanup function, which is where destroy belongs.
Build the renderer once, then push parameters at it as they change.
<script lang="ts">
import { onMount } from "svelte";
import { createShaderRenderer, type ShaderRenderer } from "shadertown";
let canvas: HTMLCanvasElement;
let renderer: ShaderRenderer | undefined;
export let speed = 0.4;
onMount(() => {
renderer = createShaderRenderer(canvas, {
shader: "aurora",
params: { speed },
});
return () => renderer?.destroy();
});
// Live, and cheap: no recompile, no new surface.
$: renderer?.setParams({ speed });
</script>
<canvas bind:this={canvas}></canvas>Astro
Astro renders on the server and the renderer is browser-only, so the call
belongs in a client <script>. A decorative shader needs no island framework
at all.
---
// No client directive, because there is no component to hydrate.
---
<canvas id="hero"></canvas>
<script>
import { createShaderRenderer } from "shadertown";
const canvas = document.getElementById("hero") as HTMLCanvasElement;
createShaderRenderer(canvas, {
shader: "mesh-gradient",
params: { speed: 0.2 },
});
</script>Astro bundles that script, so the import resolves like any other dependency. Nothing here is left to clean up: the page owns the canvas for its whole life.
Solid
onMount and onCleanup cover the lifecycle, and createEffect covers the
parameters.
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { createShaderRenderer, type ShaderRenderer } from "shadertown";
export function Hero() {
let canvas!: HTMLCanvasElement;
let renderer: ShaderRenderer | undefined;
const [speed, setSpeed] = createSignal(0.4);
onMount(() => {
renderer = createShaderRenderer(canvas, { shader: "aurora" });
onCleanup(() => renderer?.destroy());
});
createEffect(() => renderer?.setParams({ speed: speed() }));
return <canvas ref={canvas} />;
}Anything else
Vue, Angular, Lit, htmx, or a <script> tag in a hand-written page: the shape
does not change. Mount, keep the handle, destroy on the way out.
import { createShaderRenderer } from "shadertown";
const renderer = createShaderRenderer(canvas, { shader: "voronoi" });
// later
renderer.destroy();Quality
The quality tier is a page setting, not a renderer one, and it works the same without React:
import { createShaderRenderer, setQualityTier } from "shadertown";
setQualityTier("low"); // every renderer on the page follows, live
const renderer = createShaderRenderer(canvas, {
shader: "tunnel",
tier: "high", // or pin this one and ignore the page
onQuality: ({ effective, reason }) => label.textContent = `${effective} · ${reason}`,
});
renderer.setTier("auto"); // change the pin later; pass nothing to follow the page again
renderer.quality; // { preference, effective, reason }What <ShaderCanvas /> does that a plain call does not
The React component is not only a wrapper. It wires three browser behaviours that the core renderer leaves to you on purpose, because the right answer depends on the page. Outside React you get them by asking.
| Behaviour | In React | On your own |
|---|---|---|
| Stop drawing off screen | play="visible", the default | IntersectionObserver + setPaused |
| Respect reduced motion | Automatic | matchMedia + setPaused |
| Survive missing WebGPU | fallback prop | isWebGpuSupported() |
Stop drawing off screen
A shader that animates below the fold burns a GPU for nobody.
const observer = new IntersectionObserver(([entry]) => {
renderer.setPaused(!entry.isIntersecting);
});
observer.observe(canvas);Respect reduced motion
Hold a still frame rather than hiding the shader. The picture is still worth seeing; the movement is what was asked about.
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => renderer.setPaused(query.matches);
apply();
query.addEventListener("change", apply);Survive missing WebGPU
Not every browser has WebGPU yet. Ask before you build, and keep whatever you were going to show anyway.
import { createShaderRenderer, isWebGpuSupported } from "shadertown";
if (isWebGpuSupported()) {
createShaderRenderer(canvas, { shader: "aurora" });
} else {
canvas.replaceWith(fallbackElement);
}Types without React
The generics are in the core package, so a Svelte or Solid file is typed as
tightly as a React one. ShaderParams<"aurora"> knows aurora's parameters and
rejects a knob that belongs to another shader.
import { defineShader, type ShaderParams } from "shadertown";
const preset = defineShader({
shader: "aurora",
params: { speed: 0.4, intensity: 1.05 },
});
const params: ShaderParams<"aurora"> = { speed: 0.6 };See Config for presets that live in a file, and Performance for what the shared frame loop is already doing for you.