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:
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 : u3213 ) -> 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:
1// @ts-check2import { setupCanvas } from './shader.js'3
4class ShaderCanvas extends HTMLElement {5 static observedAttributes = ['centered', 'highlight', 'large']6
7 /** @type {MutationObserver} */8 #observer9
10 /** @type {HTMLCanvasElement} */11 #canvas12
13 /** @type {HTMLScriptElement} */14 #script15
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.#script33 if (initialized) {34 return35 }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 return44 }45
46 this.#observer.disconnect()47
48 this.#canvas = canvas49 this.#script = script50
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
66customElements.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
1// @ts-check2
3/**4 * @param {HTMLCanvasElement} canvas5 * @param {string} shader - WebGPU Shader6 * @returns {Promise<((saveTo?: string) => void) | undefined>} renderer function. Will be `undefined` if there is an instantiation error7 */8export async function setupCanvas(9 canvas,10 shader,11) {12 // @ts-ignore13 const adapter = await navigator.gpu?.requestAdapter()14 const device = await adapter?.requestDevice()15 if (!device) {16 return17 }18
19
20 /**21 * @type {any}22 */23 const ctx = canvas?.getContext('webgpu')24 if (!ctx) {25 return26 }27
28 // @ts-ignore29 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-ignore59 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,60 })61
62 let curr = 163
64 /**65 * @param {string} [saveTo]66 */67 function render(saveTo) {68 curr += 0.169
70
71 // https://stackoverflow.com/questions/70284258/destroyed-texture-texture-used-in-a-submit-when-using-a-video-texture-in-ch72 // render pass descriptor needs to be recreated since this doesn't live very long on the GPU73 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 times102 pass.end()103
104 const commandBuffer = encoder.finish()105 device.queue.submit([commandBuffer])106 if (saveTo) {107 // saving must be done during the render108 downloadCanvas(canvas, saveTo)109 }110 }111
112 return render113}114
115/**116 * @param {HTMLCanvasElement} canvas117 * @param {string} name118 */119function 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 = data125 link.click()126 link.parentNode?.removeChild(link)127}