WebGPU icon shader
row = floor(uv.y × 17) gradX = fract(uv.x + row·sin(t·0.001) + ease(pingPong(t·0.05 + row·0.025)))
Palette
The four colours are uniforms, not constants — swapping them costs nothing, because the shader never had them baked in.
Sizes
Nothing is rasterised at a fixed resolution, so the same shader draws the 16px icon and the hero above it. The row count is in UV space rather than pixels, which is why all seventeen bands survive the whole way down.
How it works
One triangle, no geometry
There is no vertex buffer and no mesh. The vertex shader is handed a vertex_index of 0, 1 or 2 and returns one of three hardcoded clip-space corners: (-1, 3), (-1, -1) and (3, -1). That triangle is twice the size it needs to be, which means it covers the entire clip cube with a single primitive — cheaper to set up than the two triangles of a quad, and with no diagonal seam through the middle where the two halves meet.
The UVs are hardcoded alongside it as 0 to 2, so they interpolate to exactly 0 to 1 across the visible square while the overhang runs off screen. Everything after that point is a per-pixel decision in the fragment shader.
Seventeen rows out of phase
The icon reads as horizontal bands because floor(uv.y * 17) quantises the vertical axis into 17 rows, and every pixel in a row therefore shares one row index. That index is the only thing separating a row from its neighbours: it adds row × 0.025 to the animation phase and a slow row × sin(time × 0.001) to the base offset.
A fixed offset per row would give a static staircase. Advancing each row at the same rate but from a different starting phase gives the opposite — the bands are always mid-sweep at slightly different points, which is what makes the icon look like it is being combed rather than scrolled.
Ping-pong, then ease
Sweeping with a raw fract(t) ramp would snap back to the start each cycle. Instead the phase goes through abs(fract(t) × 2 − 1), a triangle wave that runs 0 → 1 → 0, so the motion reverses instead of jumping. The cost of a triangle wave is a hard corner in velocity at each end, so the result is passed through easeInOutCubic, which flattens the derivative at 0 and 1 and turns those corners into pauses.
The eased value is added to uv.x and wrapped with fract, so the gradient slides horizontally and repeats seamlessly no matter how far the offset has drifted.
Four colours, five stops
The palette arrives as four vec4f uniforms and is loaded into a five-element array with the first colour repeated at the end. That repeat is the whole trick: gradX is multiplied by 3 and split into segment = u32(t) and f = fract(t), so a mix(colors[segment], colors[segment + 1], f) walks the four colours and then lands back on the first — the wrap point has matching colours on both sides and the seam disappears.
On top of that goes film grain: fract(sin(dot(uv, vec2f(12.9898, 78.233))) × 43758.5453) is the standard GPU hash — no textures, no random source, just a sine deliberately pushed far past its useful precision so the low bits are effectively noise. Scaled to ±0.03 and added, it breaks up the banding that eight-bit interpolation leaves across a gradient this shallow.
One uniform buffer
Everything the shader needs to know per frame fits in 80 bytes: resolution, time, a pad float, and four colours. It is written once per frame with queue.writeBuffer and nothing else is uploaded, so a frame is one buffer write, one draw call of three vertices, and a submit.
The pad float is not decoration. WGSL aligns a vec4f to 16 bytes, so without a fourth float after resolution.xy and time the colours would start at the wrong offset and every one of them would be read from the wrong place. The colours themselves are vec4f for the same reason, with the alpha component unused.
The loop parks itself when the canvas scrolls off screen or the tab goes to the background, via an IntersectionObserver and visibilitychange, and wakes on either. A shader nobody can see is not worth a GPU submit — and on the crafts index it would otherwise be competing for frames with every canvas piece on the page.
Without WebGPU
Browsers with no navigator.gpu, no adapter, or a device that is lost mid-session fall back to a CSS gradient built from the same four colours, panning back and forth. It animates a transform on an over-wide layer inside an overflow: hidden box rather than a moving background-position, because the former is handed to the compositor while the latter costs a style recalculation and a repaint on every frame.
The same gradient covers the gap before the real shader arrives, then cross-fades out and stops animating — an infinite animation on an element faded to opacity: 0 keeps running, and keeps costing, forever.
Source
The whole thing — vertex, fragment, and the uniform struct they share.
struct Uniforms { resolution: vec2f, time: f32, _pad: f32, color0: vec4f, color1: vec4f, color2: vec4f, color3: vec4f,};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
struct VertexOutput { @builtin(position) position: vec4f, @location(0) uv: vec2f,};
@vertexfn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { let pos = array( vec2f(-1.0, 3.0), vec2f(-1.0, -1.0), vec2f( 3.0, -1.0) ); let uvs = array( vec2f(0.0, 0.0), vec2f(0.0, 2.0), vec2f(2.0, 2.0) ); var output: VertexOutput; output.position = vec4f(pos[vertexIndex], 0.0, 1.0); output.uv = uvs[vertexIndex]; return output;}
fn rand(co: vec2f) -> f32 { return fract(sin(dot(co, vec2f(12.9898, 78.233))) * 43758.5453);}
fn grain(uv: vec2f, strength: f32) -> f32 { return (rand(uv * 1000.0) - 0.5) * strength;}
fn easeInOutCubic(t: f32) -> f32 { if (t < 0.5) { return 4.0 * t * t * t; } return 1.0 - pow(-2.0 * t + 2.0, 3.0) / 2.0;}
fn easedPingPong(t: f32) -> f32 { let pingPong = abs(fract(t) * 2.0 - 1.0); return easeInOutCubic(pingPong);}
@fragmentfn fs_main(input: VertexOutput) -> @location(0) vec4f { let fragCoord = input.position.xy; let uv = input.uv; let time = uniforms.time;
let c0 = uniforms.color0.rgb; let c1 = uniforms.color1.rgb; let c2 = uniforms.color2.rgb; let c3 = uniforms.color3.rgb;
let numRows = 17.0; let rowIndex = floor(uv.y * numRows); let a2 = sin(time * 0.001 + rowIndex * 0.0001); let baseOffset = rowIndex * a2;
let animSpeed = 0.05; let phase = time * animSpeed + rowIndex * 0.025; let eased = easedPingPong(phase); let animOffset = eased * 1.0;
let gradX = fract(uv.x + baseOffset + animOffset); let colors = array<vec3f, 5>(c0, c1, c2, c3, c0);
let t = gradX * 3.0; let segment = u32(t); let f = fract(t);
var color = mix(colors[segment], colors[segment + 1], f);
// grain let normalizedCoord = fragCoord / uniforms.resolution; let noiseVal = grain(normalizedCoord * 10.0, 0.06); color = color + vec3f(noiseVal);
return vec4f(color, 1.0);}Device setup, the per-frame uniform write, visibility gating, and the fallback.
'use client';
import { useEffect, useRef, useState } from 'react';import { iconShader } from '@/lib/iconShader';
// WebGPU type declarationsdeclare global { interface Navigator { gpu?: { requestAdapter(): Promise<GPUAdapter | null>; getPreferredCanvasFormat(): string; }; }
interface GPUAdapter { requestDevice(): Promise<GPUDevice>; }
interface GPUCommandEncoder { beginRenderPass(descriptor: unknown): GPURenderPassEncoder; finish(): unknown; }
interface GPURenderPassEncoder { setPipeline(pipeline: unknown): void; setBindGroup(index: number, bindGroup: unknown): void; draw(vertexCount: number): void; end(): void; }
interface GPUDevice { createShaderModule(descriptor: unknown): unknown; createBuffer(descriptor: unknown): unknown; createBindGroupLayout(descriptor: unknown): unknown; createBindGroup(descriptor: unknown): unknown; createPipelineLayout(descriptor: unknown): unknown; createRenderPipeline(descriptor: unknown): unknown; createCommandEncoder(descriptor: unknown): GPUCommandEncoder; queue: { writeBuffer(buffer: unknown, offset: number, data: BufferSource | ArrayBuffer): void; submit(commandBuffers: unknown[]): void; }; lost: Promise<{ reason: string; message: string }>; }
interface GPUCanvasContext { configure(configuration: unknown): void; getCurrentTexture(): { createView(): unknown; }; }
interface HTMLCanvasElement { getContext(contextId: 'webgpu'): GPUCanvasContext | null; }
// eslint-disable-next-line no-var var GPUBufferUsage: { UNIFORM: number; COPY_DST: number; };
// eslint-disable-next-line no-var var GPUShaderStage: { VERTEX: number; FRAGMENT: number; };}
function hexToRgb(hex: string): [number, number, number] { const h = hex.replace('#', ''); return [ parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255, ];}
export const DEFAULT_COLORS = ['#ff8033', '#ff9966', '#ffd94d', '#ffcc99'] as const;
interface TestShaderIconProps { className?: string; size?: number; colors?: readonly [string, string, string, string]; /** Stretch to the parent box instead of drawing at a fixed `size`. */ fill?: boolean;}
export default function TestShaderIcon({ className = '', size = 64, colors = DEFAULT_COLORS, fill = false }: TestShaderIconProps) { const canvasRef = useRef<HTMLCanvasElement>(null); const animationFrameRef = useRef<number | undefined>(undefined); const startTimeRef = useRef<number>(Date.now()); const deviceRef = useRef<GPUDevice | null>(null); const colorsRef = useRef(colors); const readyRef = useRef(false); const [webGPUSupported, setWebGPUSupported] = useState(true); const [isReady, setIsReady] = useState(false);
// Keep colorsRef in sync so the render loop always has the latest colours useEffect(() => { colorsRef.current = colors; }, [colors]);
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return;
setIsReady(false);
let ro: ResizeObserver | null = null; let io: IntersectionObserver | null = null;
/* * The shader has no reason to keep drawing once it has scrolled away, and on * the crafts index it would otherwise compete for frames with every canvas * piece on the page. `wake` restarts the loop, and the loop parks itself. */ let onScreen = true; let wake: (() => void) | null = null; const awake = () => onScreen && !document.hidden;
// In fill mode the backing store tracks the CSS box; otherwise it's a fixed square. const sizeCanvas = () => { if (!fill) { canvas.width = size; canvas.height = size; return; } const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = Math.max(1, Math.round(rect.width * dpr)); const h = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } };
const initWebGPU = async () => { try { if (!navigator.gpu) { setWebGPUSupported(false); return; }
sizeCanvas(); if (fill) { ro = new ResizeObserver(sizeCanvas); ro.observe(canvas); }
const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { setWebGPUSupported(false); return; }
const device = await adapter.requestDevice(); deviceRef.current = device;
device.lost.then(() => { setWebGPUSupported(false); deviceRef.current = null; });
const context = canvas.getContext('webgpu'); if (!context) { setWebGPUSupported(false); return; }
const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format: canvasFormat, alphaMode: 'premultiplied' });
const shaderModule = device.createShaderModule({ label: 'icon shader', code: iconShader });
// 80 bytes = 20 floats const uniformBuffer = device.createBuffer({ label: 'uniforms', size: 80, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, });
const bindGroupLayout = device.createBindGroupLayout({ label: 'bind group layout', entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' }, }], });
const bindGroup = device.createBindGroup({ label: 'bind group', layout: bindGroupLayout, entries: [{ binding: 0, resource: { buffer: uniformBuffer } }], });
const pipeline = device.createRenderPipeline({ label: 'icon pipeline', layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }), vertex: { module: shaderModule, entryPoint: 'vs_main' }, fragment: { module: shaderModule, entryPoint: 'fs_main', targets: [{ format: canvasFormat }] }, primitive: { topology: 'triangle-list' }, });
const render = () => { if (!deviceRef.current) return; if (!awake()) { // Park until something wakes us, rather than burning a frame to check. animationFrameRef.current = undefined; return; } try { const time = (Date.now() - startTimeRef.current) / 1000; const [r0, g0, b0] = hexToRgb(colorsRef.current[0]); const [r1, g1, b1] = hexToRgb(colorsRef.current[1]); const [r2, g2, b2] = hexToRgb(colorsRef.current[2]); const [r3, g3, b3] = hexToRgb(colorsRef.current[3]);
const uniformData = new Float32Array([ canvas.width, canvas.height, time, 0, r0, g0, b0, 0, r1, g1, b1, 0, r2, g2, b2, 0, r3, g3, b3, 0, ]); device.queue.writeBuffer(uniformBuffer, 0, uniformData);
const encoder = device.createCommandEncoder({ label: 'encoder' }); const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: [0, 0, 0, 1], loadOp: 'clear', storeOp: 'store', }], });
pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.draw(3); pass.end();
device.queue.submit([encoder.finish()]); if (!readyRef.current) { readyRef.current = true; setIsReady(true); } animationFrameRef.current = requestAnimationFrame(render); } catch { setWebGPUSupported(false); deviceRef.current = null; } };
wake = () => { if (animationFrameRef.current === undefined && awake() && deviceRef.current) { animationFrameRef.current = requestAnimationFrame(render); } };
io = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; wake?.(); }, { threshold: 0.01 }); io.observe(canvas);
render(); } catch { setWebGPUSupported(false); } };
const onVisibility = () => wake?.(); document.addEventListener('visibilitychange', onVisibility);
initWebGPU();
return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = undefined; } wake = null; document.removeEventListener('visibilitychange', onVisibility); io?.disconnect(); ro?.disconnect(); deviceRef.current = null; }; }, [size, fill]);
const gradientFallback = `linear-gradient(90deg, ${colors[0]}, ${colors[1]}, ${colors[2]}, ${colors[3]}, ${colors[0]})`;
// Fill mode inherits the parent's box and corner rounding; fixed mode is a rounded square. const box: React.CSSProperties = fill ? { width: '100%', height: '100%' } : { width: `${size}px`, height: `${size}px` }; const radius = fill ? undefined : '0.5rem';
/* * The panning gradient shown before — or instead of — the shader. The motion is * a transform on an over-wide layer rather than a moving background-position: * the former is handed to the compositor, where the latter cost a style * recalculation and a repaint on every frame. `animate` goes false the moment * the real shader takes over, because an infinite animation on an element * faded to opacity 0 keeps running, and paying, forever. */ const panningGradient = (animate: boolean) => ( <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', borderRadius: radius, zIndex: 0, pointerEvents: 'none', }} aria-hidden > <div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, width: '200%', background: gradientFallback, animation: animate ? 'shaderPan 3s ease-in-out infinite alternate' : undefined, }} /> </div> );
if (!webGPUSupported) { return ( <div className={className} style={{ ...box, position: 'relative', display: 'block' }}> {panningGradient(true)} </div> ); }
return ( <div className={className} style={{ position: 'relative', display: 'block', ...box }}> <div style={{ position: 'absolute', inset: 0, opacity: isReady ? 0 : 1, transition: 'opacity 0.4s ease' }}> {panningGradient(!isReady)} </div> <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block', borderRadius: radius, position: 'relative', zIndex: 1, opacity: isReady ? 1 : 0, transition: 'opacity 0.4s ease', }} /> </div> );}