Shader Web Component

09 November 2025

Updated: 09 November 2025

So I want to use some more shaders and I want to also migrate everything over to use wgsl instead of glsl but it’s kinda annoying to set them up every time so I made a little web component for using them, this is very much a work-in-progress, but here’s it in action:

Using the component looks like so:

page.html
1
<script type="module" src="/web-components/shader-canvas.js"></script>
2
3
<site-shader-canvas>
4
<canvas />
5
<script type="text/wgsl">
6
struct VertexOutput {
7
@builtin(position) position: vec4f,
8
@location(0) texcoord: vec2f,
9
};
10
11
@vertex fn vs(
12
@builtin(vertex_index) vertexIndex : u32
13
) -> VertexOutput {
14
const pos = array(
15
vec2( 1.0, 1.0),
16
vec2( 1.0, -1.0),
17
vec2(-1.0, -1.0),
18
vec2( 1.0, 1.0),
19
vec2(-1.0, -1.0),
20
vec2(-1.0, 1.0),
21
);
22
23
var vsOutput: VertexOutput;
24
25
let xy = pos[vertexIndex];
26
vsOutput.texcoord = pos[vertexIndex] * vec2f(0.5, 0.5) + vec2f(0.5);
27
vsOutput.position = vec4f(pos[vertexIndex], 0, 1);
28
29
return vsOutput;
30
}
31
32
@group(0) @binding(0) var<uniform> uTime: f32;
33
34
@fragment fn fs(fsInput: VertexOutput) -> @location(0) vec4f {
35
var red = abs(sin(uTime/10.0)) * fsInput.texcoord.x;
36
var blue = abs(cos(uTime/5.0)) * fsInput.texcoord.y;
37
return vec4f(red, 0.0, blue, 1.0);
38
}
39
</script>
40
</site-shader-canvas>

The component code is:

/shader-canvas.js
1
// @ts-check
2
import { setupCanvas } from './shader.js'
3
4
class ShaderCanvas extends HTMLElement {
5
static observedAttributes = ['centered', 'highlight', 'large']
6
7
/** @type {MutationObserver} */
8
#observer
9
10
/** @type {HTMLCanvasElement} */
11
#canvas
12
13
/** @type {HTMLScriptElement} */
14
#script
15
16
constructor() {
17
super()
18
this.#observer = new MutationObserver(() => this.#initialize())
19
this.#observer.observe(this, { childList: true })
20
}
21
22
disconnectedCallback() {
23
this.#observer.disconnect()
24
}
25
26
connectedCallback() {
27
this.#initialize()
28
}
29
30
async #initialize() {
31
console.log('here')
32
const initialized = this.#canvas && this.#script
33
if (initialized) {
34
return
35
}
36
37
const canvas = this.querySelector('canvas')
38
39
/** @type {HTMLScriptElement} */
40
const script = this.querySelector('script[type="text/wgsl"]')
41
42
if (!(script && canvas)) {
43
return
44
}
45
46
this.#observer.disconnect()
47
48
this.#canvas = canvas
49
this.#script = script
50
51
console.log(canvas, script)
52
53
const render = await setupCanvas(this.#canvas, this.#script.innerText)
54
55
function renderLoop() {
56
requestAnimationFrame(() => {
57
render?.()
58
renderLoop()
59
})
60
}
61
62
renderLoop()
63
}
64
}
65
66
customElements.define('site-shader-canvas', ShaderCanvas)

And the code for actually doing the shader rendering pipeline is and is a heavily simplified version of what I’m currently using for my Image Editor

shader.js
1
// @ts-check
2
3
/**
4
* @param {HTMLCanvasElement} canvas
5
* @param {string} shader - WebGPU Shader
6
* @returns {Promise<((saveTo?: string) => void) | undefined>} renderer function. Will be `undefined` if there is an instantiation error
7
*/
8
export async function setupCanvas(
9
canvas,
10
shader,
11
) {
12
// @ts-ignore
13
const adapter = await navigator.gpu?.requestAdapter()
14
const device = await adapter?.requestDevice()
15
if (!device) {
16
return
17
}
18
19
20
/**
21
* @type {any}
22
*/
23
const ctx = canvas?.getContext('webgpu')
24
if (!ctx) {
25
return
26
}
27
28
// @ts-ignore
29
const format = navigator.gpu.getPreferredCanvasFormat()
30
ctx.configure({
31
device,
32
format,
33
})
34
35
const module = device.createShaderModule({
36
label: 'base shader',
37
code: shader,
38
})
39
40
const pipeline = device.createRenderPipeline({
41
label: 'render pipeline',
42
layout: 'auto',
43
vertex: {
44
module,
45
},
46
fragment: {
47
module,
48
targets: [
49
{
50
format,
51
},
52
],
53
},
54
})
55
56
const uTime = device.createBuffer({
57
size: [4],
58
// @ts-ignore
59
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
60
})
61
62
let curr = 1
63
64
/**
65
* @param {string} [saveTo]
66
*/
67
function render(saveTo) {
68
curr += 0.1
69
70
71
// https://stackoverflow.com/questions/70284258/destroyed-texture-texture-used-in-a-submit-when-using-a-video-texture-in-ch
72
// render pass descriptor needs to be recreated since this doesn't live very long on the GPU
73
const renderPassDescriptor = {
74
label: 'render pass descriptor',
75
colorAttachments: [
76
{
77
loadOp: 'clear',
78
storeOp: 'store',
79
clearValue: [0, 0, 0, 0],
80
view: ctx.getCurrentTexture().createView(),
81
},
82
],
83
}
84
85
const bindGroup = device.createBindGroup({
86
layout: pipeline.getBindGroupLayout(0),
87
entries: [{
88
binding: 0,
89
resource: { buffer: uTime }
90
}],
91
})
92
93
const encoder = device.createCommandEncoder({ label: 'command encoder' })
94
const pass = encoder.beginRenderPass(renderPassDescriptor)
95
96
pass.setPipeline(pipeline)
97
pass.setBindGroup(0, bindGroup)
98
99
device.queue.writeBuffer(uTime, 0, new Float32Array([curr]));
100
101
pass.draw(6) // call our vertex shader 6 times
102
pass.end()
103
104
const commandBuffer = encoder.finish()
105
device.queue.submit([commandBuffer])
106
if (saveTo) {
107
// saving must be done during the render
108
downloadCanvas(canvas, saveTo)
109
}
110
}
111
112
return render
113
}
114
115
/**
116
* @param {HTMLCanvasElement} canvas
117
* @param {string} name
118
*/
119
function downloadCanvas(canvas, name) {
120
const data = canvas.toDataURL('image/png')
121
const link = document.createElement('a')
122
123
link.download = name.split('.').slice(0, -1).join('.') + '.png'
124
link.href = data
125
link.click()
126
link.parentNode?.removeChild(link)
127
}