shadertown

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:

  1. Give the renderer a mounted <canvas>.
  2. Call setParams when your state changes. It never recompiles a pipeline.
  3. 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.

BehaviourIn ReactOn your own
Stop drawing off screenplay="visible", the defaultIntersectionObserver + setPaused
Respect reduced motionAutomaticmatchMedia + setPaused
Survive missing WebGPUfallback propisWebGpuSupported()

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.