Shape Waves
A field of squares, circles and triangles picked by drifting noise, with ripples that roll out from the pointer and change the shapes as they pass. React Bits' WebGPU background, ported to WebGL2 and plain JavaScript so it runs in any browser and behind any page, React or not. It is what moves behind the headline on this site's home page.
Made by React Bits. Original, with its source, at reactbits.dev/backgrounds/shape-waves. Free to copy.
Move the pointer over it
Install
Nothing to install. Copy the files below into your project and import the component.
shape-waves.js
The engine. Plain JavaScript, no React needed: put it in /public, load it with a script tag and call ShapeWaves.mount(element, options).
/* Shape Waves, the React Bits background (reactbits.dev/backgrounds/shape-waves,
MIT, itself inspired by vercel.com/labs), ported from WebGPU + React to
WebGL2 + plain JavaScript so it can run behind the hero of a static page.
What it does: a grid of cells, each drawn as a square, a circle or a
triangle depending on a band of slow-moving Perlin noise. The pointer drops
ripples into a CPU wave simulation, one height per cell, and a cell's
charge shifts its band, so waves of changing shapes roll out from the
cursor and tint towards the splash colour. An intro sweep grows the shapes
in from the centre once. The edges fade to the background.
Left out on purpose: the text cut-out (the hero's text is real HTML on
top, not drawn here) and the glow pass (it only lifts pixels brighter than
the glow threshold, which on a white page is nothing). Changed on purpose:
the canvas is transparent and paints only the shapes, so it can hang over
the seam between two sections and let each one's background show through.
Everything else is the original's logic, constants included.
Usage: ShapeWaves.mount(rootElement, { color:'#d2d2d7', ... })
Returns { update(options), replay(), destroy() }, or null when WebGL2 is
unavailable. The root should be positioned; the canvas fills it. */
(function () {
'use strict';
const SHAPE_MODES = { mixed: 0, squares: 1, circles: 2, triangles: 3 };
const MAX_DPR = 2;
const NOISE_CELLS = 32;
const TIME_RATE = 0.1;
const SIMULATION_STEP = 1 / 60;
const WAVE_SPEED = 0.42;
const WAVE_FRICTION = 0.94;
const WAVE_DECAY = 0.972;
const SETTLED_THRESHOLD = 0.01;
const INTRO_BAND = 0.2;
const INTRO_WARP = 0.3;
const INTRO_JITTER = 0.16;
const INTRO_END = 1 + INTRO_WARP + INTRO_JITTER + INTRO_BAND;
const DEFAULTS = {
shapes: 'mixed',
cellSize: 10,
dotSize: 0.75,
color: '#929292',
hoverColor: '#ffffff',
speed: 1,
scale: 1,
contrast: 1,
brightness: 0.4,
flow: 0,
direction: 0,
fade: 0.25,
interactive: true,
splashRadius: 40,
splashStrength: 0.4,
intro: true,
introDuration: 1.6,
paused: false,
};
const VERT = `#version 300 es
void main(){
// One triangle that covers the clip space; no buffers needed.
vec2 p = vec2(float((gl_VertexID & 1) << 2) - 1.0, float((gl_VertexID & 2) << 1) - 1.0);
gl_Position = vec4(p, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
precision highp int;
uniform vec4 uResolution; // w, h, 1/w, 1/h
uniform vec4 uPlacement; // originX, originY, rows, introProgress
uniform vec4 uGrid; // cellPx, dotSize, mode, cols
uniform vec4 uField; // noise scale, brightness shift, contrast, time
uniform vec4 uMotion; // driftX, driftY, unused, fade
uniform vec3 uColor;
uniform vec3 uHover;
uniform sampler2D uCharges; // cols x rows, one float per cell
out vec4 fragColor;
const vec2 SEED = vec2(12.9898, 78.233);
const float INTRO_END = ${INTRO_END.toFixed(2)};
const float INTRO_WARP = ${INTRO_WARP.toFixed(2)};
const float INTRO_JITTER = ${INTRO_JITTER.toFixed(2)};
const float INTRO_BAND = ${INTRO_BAND.toFixed(2)};
vec3 mod289(vec3 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x){ return mod289(((x * 34.0) + 10.0) * x); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
vec3 fadeCurve(vec3 t){ return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); }
float cnoise(vec3 P){
vec3 Pi0 = floor(P);
vec3 Pi1 = Pi0 + vec3(1.0);
Pi0 = mod289(Pi0);
Pi1 = mod289(Pi1);
vec3 Pf0 = fract(P);
vec3 Pf1 = Pf0 - vec3(1.0);
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;
vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);
vec4 gx0 = ixy0 * (1.0 / 7.0);
vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
vec4 gx1 = ixy1 * (1.0 / 7.0);
vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
vec3 g000 = vec3(gx0.x, gy0.x, gz0.x);
vec3 g100 = vec3(gx0.y, gy0.y, gz0.y);
vec3 g010 = vec3(gx0.z, gy0.z, gz0.z);
vec3 g110 = vec3(gx0.w, gy0.w, gz0.w);
vec3 g001 = vec3(gx1.x, gy1.x, gz1.x);
vec3 g101 = vec3(gx1.y, gy1.y, gz1.y);
vec3 g011 = vec3(gx1.z, gy1.z, gz1.z);
vec3 g111 = vec3(gx1.w, gy1.w, gz1.w);
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x; g010 *= norm0.y; g100 *= norm0.z; g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x; g011 *= norm1.y; g101 *= norm1.z; g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);
vec3 f = fadeCurve(Pf0);
vec4 nz = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), f.z);
vec2 ny = mix(nz.xy, nz.zw, f.y);
return 2.2 * mix(ny.x, ny.y, f.x);
}
float fbm(vec3 p){
float total = 0.0;
float amplitude = 1.0;
float weight = 0.0;
float frequency = 1.0;
for (int i = 0; i < 2; i++){
total += amplitude * cnoise(p * frequency);
weight += amplitude;
amplitude *= 0.5;
frequency *= 2.0;
}
return total / weight;
}
float sdIsoscelesTriangle(vec2 point, vec2 q){
vec2 p = vec2(abs(point.x), point.y);
vec2 a = p - q * clamp(dot(p, q) / dot(q, q), 0.0, 1.0);
vec2 b = p - q * vec2(clamp(p.x / q.x, 0.0, 1.0), 1.0);
float s = -sign(q.y);
vec2 d = min(vec2(dot(a, a), s * (p.x * q.y - p.y * q.x)), vec2(dot(b, b), s * (p.y - q.y)));
return -sqrt(d.x) * sign(d.y);
}
float shapeDistance(vec2 p, int shape, float c){
if (shape == 0) return max(abs(p.x), abs(p.y)) - c;
if (shape == 1) return length(p) - c;
return sdIsoscelesTriangle(vec2(p.x, p.y + c), vec2(c, 2.0 * c));
}
float hash21(vec2 p){
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main(){
vec2 resolution = uResolution.xy;
float cellPx = uGrid.x;
float dotSize = uGrid.y;
int mode = int(uGrid.z + 0.5);
int cols = int(uGrid.w + 0.5);
// y down, like the simulation grid and the pointer.
vec2 pixel = vec2(gl_FragCoord.x, resolution.y - gl_FragCoord.y);
vec2 uv = pixel / resolution;
vec2 origin = uPlacement.xy;
int rows = int(uPlacement.z + 0.5);
vec2 cell = floor((pixel - origin) / cellPx);
if (cell.y < 0.0 || int(cell.y) >= rows || cell.x < 0.0 || int(cell.x) >= cols){
fragColor = vec4(0.0);
return;
}
vec2 center = origin + (cell + 0.5) * cellPx;
vec2 local = (pixel - center) / (cellPx * 0.5);
vec2 cellUv = center / resolution;
float level = 1.0;
float fadeAmt = uMotion.w;
if (fadeAmt > 0.0){
vec2 q = abs(uv * 2.0 - 1.0);
float radius = pow(pow(q.x, 2.5) + pow(q.y, 2.5), 1.0 / 2.5) / pow(2.0, 1.0 / 2.5);
level = 1.0 - smoothstep(max(0.0, 1.0 - fadeAmt * 2.2), 1.0, radius);
}
float noise = fbm(vec3((center + uMotion.xy) / uField.x + SEED, uField.w));
float tone = clamp((noise * 0.5 + 0.5 - uField.y) * uField.z + 0.5, 0.0, 1.0);
int band = int(min(tone, 0.999999) * 3.0);
float charge = texelFetch(uCharges, ivec2(int(cell.x), int(cell.y)), 0).r;
int stepped = (band + int(clamp(charge, 0.0, 0.999) * 3.0)) % 3;
int shape = 2 - stepped;
float size = dotSize;
if (mode != 0){
shape = mode - 1;
size = dotSize * mix(0.45, 1.0, float(stepped) / 2.0);
}
float introProgress = uPlacement.w;
float front = 0.0;
if (introProgress < INTRO_END){
float radial = length((center - resolution * 0.5) / (resolution * 0.5)) * 0.70710678;
float warp = cnoise(vec3(cellUv * vec2(3.2, 2.4) + SEED, 4.7)) * INTRO_WARP;
float jitter = hash21(cell) * INTRO_JITTER;
float spread = radial + warp + jitter + INTRO_WARP;
float bandW = INTRO_BAND * (0.6 + 0.8 * hash21(cell + vec2(17.0, 9.0)));
float t = clamp((introProgress - spread) / bandW, 0.0, 1.0);
if (t <= 0.0){
fragColor = vec4(0.0);
return;
}
float back = t - 1.0;
size = max(size * (1.0 + 2.70158 * back * back * back + 1.70158 * back * back), 0.02);
front = 1.0 - smoothstep(0.0, 1.0, abs(introProgress - spread) / bandW);
}
float aa = 2.0 / cellPx;
float coverage = smoothstep(aa, -aa, shapeDistance(local, shape, size));
vec3 tint = mix(uColor, uHover, max(smoothstep(0.15, 0.85, charge), front * 0.35));
float alpha = coverage * level;
fragColor = vec4(tint * alpha, alpha); // premultiplied
}`;
function parseColor(value, fallback) {
const source = typeof value === 'string' ? value.trim() : '';
const match = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(source) || /^#?([\da-f]{6})$/i.exec(fallback);
let hex = match[1];
if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c);
return [0, 2, 4].map((o) => parseInt(hex.slice(o, o + 2), 16) / 255);
}
function compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error('ShapeWaves shader: ' + log);
}
return shader;
}
function mount(root, options) {
if (!root) return null;
const settings = Object.assign({}, DEFAULTS, options || {});
settings.cellSize = Math.max(2, settings.cellSize);
settings.scale = Math.max(0.05, settings.scale);
settings.introDuration = Math.max(0.1, settings.introDuration);
const canvas = document.createElement('canvas');
canvas.className = 'shape-waves__canvas';
// Fills the root and fades in on the first frame; no stylesheet needed.
Object.assign(canvas.style, {
position: 'absolute', inset: '0', display: 'block', width: '100%', height: '100%',
opacity: '0', transition: 'opacity .6s cubic-bezier(.22,1,.36,1)', pointerEvents: 'none',
});
root.appendChild(canvas);
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
depth: false,
stencil: false,
powerPreference: 'low-power',
preserveDrawingBuffer: false,
});
if (!gl) {
canvas.remove();
return null;
}
let program;
try {
program = gl.createProgram();
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
} catch (error) {
canvas.remove();
if (typeof console !== 'undefined') console.error('[ShapeWaves]', error);
return null;
}
gl.useProgram(program);
const U = {};
['uResolution', 'uPlacement', 'uGrid', 'uField', 'uMotion', 'uColor', 'uHover', 'uCharges'].forEach(
(name) => { U[name] = gl.getUniformLocation(program, name); }
);
// WebGL2 needs a VAO bound to draw, even with no attributes.
gl.bindVertexArray(gl.createVertexArray());
const chargeTexture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, chargeTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.uniform1i(U.uCharges, 0);
let disposed = false;
let frameId = 0;
let lastFrameTime = 0;
let time = 0;
let drift = [0, 0];
let dpr = 1;
let visible = true;
let presented = false;
let cols = 1;
let rows = 1;
let cellPx = 10;
let gridOrigin = [0, 0];
let charges = new Float32Array(1);
let heights = new Float32Array(1);
let previousHeights = new Float32Array(1);
let simulationBacklog = 0;
let introStart = 0;
let introProgress = INTRO_END;
let introArmed = false;
let chargesActive = false;
let bounds = null;
let width = 1;
let height = 1;
const pointer = { x: 0, y: 0, at: 0, inside: false };
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const uploadCharges = () => {
gl.bindTexture(gl.TEXTURE_2D, chargeTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, cols, rows, 0, gl.RED, gl.FLOAT, charges);
};
const invalidateBounds = () => { bounds = null; };
const splash = (x, y, strength) => {
const sigma = Math.max(0.5, ((settings.splashRadius * dpr) / cellPx) * 0.5);
const reach = Math.ceil(sigma * 2.5);
const centerCol = (x * dpr - gridOrigin[0]) / cellPx - 0.5;
const centerRow = (y * dpr - gridOrigin[1]) / cellPx - 0.5;
const minRow = Math.max(0, Math.floor(centerRow - reach));
const maxRow = Math.min(rows - 1, Math.ceil(centerRow + reach));
const minCol = Math.max(0, Math.floor(centerCol - reach));
const maxCol = Math.min(cols - 1, Math.ceil(centerCol + reach));
for (let row = minRow; row <= maxRow; row++) {
const dy = row - centerRow;
for (let col = minCol; col <= maxCol; col++) {
const dx = col - centerCol;
const bump = strength * Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));
const index = row * cols + col;
heights[index] = Math.min(1.2, heights[index] + bump);
}
}
chargesActive = true;
};
const handlePointerMove = (event) => {
if (!settings.interactive) return;
if (!bounds) bounds = root.getBoundingClientRect();
const now = performance.now();
const x = event.clientX - bounds.left;
const y = event.clientY - bounds.top;
const inside = x >= 0 && y >= 0 && x <= bounds.width && y <= bounds.height;
if (inside) {
const elapsed = pointer.inside ? Math.max(8, now - pointer.at) : 16;
const travelled = pointer.inside ? Math.hypot(x - pointer.x, y - pointer.y) : 0;
const speed = (travelled / elapsed) * 1000;
splash(x, y, Math.min(1, 0.22 + speed * 0.0006) * settings.splashStrength);
wake();
}
pointer.x = x;
pointer.y = y;
pointer.at = now;
pointer.inside = inside;
};
const configureGrid = () => {
const nextCols = Math.max(1, Math.round(width / (settings.cellSize * dpr)));
cellPx = width / nextCols;
const nextRows = Math.max(1, Math.floor(height / cellPx));
gridOrigin = [0, (height - nextRows * cellPx) / 2];
if (nextCols === cols && nextRows === rows && charges.length === cols * rows) return;
cols = nextCols;
rows = nextRows;
charges = new Float32Array(cols * rows);
heights = new Float32Array(cols * rows);
previousHeights = new Float32Array(cols * rows);
chargesActive = false;
uploadCharges();
};
const stepRipples = () => {
const lastCol = cols - 1;
const lastRow = rows - 1;
let peak = 0;
for (let row = 0; row < rows; row++) {
const up = (row === 0 ? row : row - 1) * cols;
const down = (row === lastRow ? row : row + 1) * cols;
const base = row * cols;
for (let col = 0; col < cols; col++) {
const index = base + col;
const left = base + (col === 0 ? col : col - 1);
const right = base + (col === lastCol ? col : col + 1);
const h = heights[index];
const laplacian = heights[left] + heights[right] + heights[up + col] + heights[down + col] - 4 * h;
const velocity = (h - previousHeights[index]) * WAVE_FRICTION;
const next = (h + velocity + WAVE_SPEED * laplacian) * WAVE_DECAY;
previousHeights[index] = next;
const charge = Math.min(1, Math.max(0, next));
charges[index] = charge;
if (charge > peak) peak = charge;
}
}
const swap = heights;
heights = previousHeights;
previousHeights = swap;
return peak;
};
const updateCharges = (deltaSeconds) => {
if (!chargesActive) return false;
simulationBacklog = Math.min(simulationBacklog + deltaSeconds, SIMULATION_STEP * 4);
let peak = 1;
while (simulationBacklog >= SIMULATION_STEP) {
simulationBacklog -= SIMULATION_STEP;
peak = stepRipples();
}
if (peak < SETTLED_THRESHOLD) {
heights.fill(0);
previousHeights.fill(0);
charges.fill(0);
chargesActive = false;
}
uploadCharges();
return chargesActive;
};
const isAnimating = () =>
visible && !document.hidden && !settings.paused && settings.speed > 0 && !reduceMotion.matches;
const render = (now) => {
frameId = 0;
if (disposed) return;
const deltaSeconds = lastFrameTime ? Math.min(0.1, (now - lastFrameTime) / 1000) : 0;
lastFrameTime = now;
const animating = isAnimating();
if (animating) {
time += deltaSeconds * TIME_RATE * settings.speed;
const angle = (settings.direction * Math.PI) / 180;
const distance = settings.flow * cellPx * deltaSeconds;
drift = [drift[0] + Math.cos(angle) * distance, drift[1] + Math.sin(angle) * distance];
}
const hovering = visible && !document.hidden && updateCharges(deltaSeconds);
if (introArmed) {
introArmed = false;
introStart = now;
introProgress = 0;
}
const introPlaying = introProgress < INTRO_END;
if (introPlaying) {
introProgress = Math.min(INTRO_END, ((now - introStart) / 1000 / settings.introDuration) * INTRO_END);
}
gl.viewport(0, 0, width, height);
gl.uniform4f(U.uResolution, width, height, 1 / width, 1 / height);
gl.uniform4f(U.uPlacement, gridOrigin[0], gridOrigin[1], rows, introProgress);
gl.uniform4f(U.uGrid, cellPx, Math.min(1, Math.max(0.1, settings.dotSize)), SHAPE_MODES[settings.shapes] || 0, cols);
gl.uniform4f(U.uField, NOISE_CELLS * cellPx * settings.scale, 0.5 - (settings.brightness - 0.5) * 0.4, 2.8 * settings.contrast, time);
gl.uniform4f(U.uMotion, drift[0], drift[1], 0, settings.fade);
const c = parseColor(settings.color, '#929292');
const h = parseColor(settings.hoverColor, '#ffffff');
gl.uniform3f(U.uColor, c[0], c[1], c[2]);
gl.uniform3f(U.uHover, h[0], h[1], h[2]);
gl.bindTexture(gl.TEXTURE_2D, chargeTexture);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (!presented) {
presented = true;
canvas.style.opacity = '1';
root.classList.add('is-ready');
}
if (animating || hovering || introPlaying) frameId = requestAnimationFrame(render);
else lastFrameTime = 0;
};
const wake = () => {
if (disposed || frameId) return;
frameId = requestAnimationFrame(render);
};
const resize = () => {
if (disposed) return;
invalidateBounds();
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
width = Math.max(1, Math.round(canvas.clientWidth * dpr));
height = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
configureGrid();
wake();
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(root);
const visibilityObserver = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
if (visible) wake();
}, { threshold: 0 });
visibilityObserver.observe(root);
document.addEventListener('visibilitychange', wake);
reduceMotion.addEventListener('change', wake);
window.addEventListener('pointermove', handlePointerMove, { passive: true });
window.addEventListener('scroll', invalidateBounds, { capture: true, passive: true });
canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); });
canvas.addEventListener('webglcontextrestored', resize);
if (settings.intro && !reduceMotion.matches) introArmed = true;
resize();
return {
// Change any option live; the grid is rebuilt if the cell size changed.
update(next) {
Object.assign(settings, next || {});
settings.cellSize = Math.max(2, settings.cellSize);
settings.scale = Math.max(0.05, settings.scale);
settings.introDuration = Math.max(0.1, settings.introDuration);
configureGrid();
if (!settings.interactive && chargesActive) {
heights.fill(0); previousHeights.fill(0); charges.fill(0);
chargesActive = false;
uploadCharges();
}
wake();
},
// Play the intro sweep again.
replay() {
if (disposed || !settings.intro || reduceMotion.matches) return;
introArmed = true;
wake();
},
destroy() {
disposed = true;
if (frameId) cancelAnimationFrame(frameId);
resizeObserver.disconnect();
visibilityObserver.disconnect();
document.removeEventListener('visibilitychange', wake);
reduceMotion.removeEventListener('change', wake);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('scroll', invalidateBounds, { capture: true });
gl.deleteTexture(chargeTexture);
gl.deleteProgram(program);
canvas.remove();
},
};
}
window.ShapeWaves = { mount };
})();
ShapeWaves.tsx
The React wrapper. Loads the engine once and mounts it in a div that fills whatever you put it in.
"use client";
import { type CSSProperties, useEffect, useRef } from "react";
/**
* Shape Waves for React: a thin wrapper around the plain-JavaScript engine
* in /public/assets/shape-waves.js (React Bits' background, ported to
* WebGL2). It loads the engine once, mounts it in a div that fills its
* parent, and forwards prop changes to it. Put it first inside any element
* with position: relative, then your content on top.
*
* <section style={{ position: "relative" }}>
* <ShapeWaves color="#929292" hoverColor="#fff" />
* <h1 style={{ position: "relative" }}>Hello</h1>
* </section>
*
* Without WebGL2 the div stays empty, so the section looks as it would
* without the effect.
*/
export type ShapeWavesOptions = {
shapes?: "mixed" | "squares" | "circles" | "triangles";
cellSize?: number;
dotSize?: number;
color?: string;
hoverColor?: string;
speed?: number;
scale?: number;
contrast?: number;
brightness?: number;
flow?: number;
direction?: number;
fade?: number;
interactive?: boolean;
splashRadius?: number;
splashStrength?: number;
intro?: boolean;
introDuration?: number;
paused?: boolean;
};
type Handle = { update(options: ShapeWavesOptions): void; replay(): void; destroy(): void };
declare global {
interface Window {
ShapeWaves?: { mount(root: HTMLElement, options?: ShapeWavesOptions): Handle | null };
}
}
export type ShapeWavesProps = ShapeWavesOptions & {
/** Where the engine is served from. */
src?: string;
/** Change this value to play the intro sweep again. */
introKey?: string | number;
className?: string;
style?: CSSProperties;
};
let loading: Promise<void> | null = null;
function loadEngine(src: string): Promise<void> {
if (window.ShapeWaves) return Promise.resolve();
if (!loading) {
loading = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => {
loading = null;
reject(new Error(`Could not load ${src}`));
};
document.head.appendChild(script);
});
}
return loading;
}
export function ShapeWaves({ src = "/assets/shape-waves.js", introKey, className, style, ...options }: ShapeWavesProps) {
const root = useRef<HTMLDivElement>(null);
const handle = useRef<Handle | null>(null);
const latest = useRef<ShapeWavesOptions>({});
const signature = JSON.stringify(options);
// The current options, kept where the effects below can read them without
// re-mounting the engine on every render. Declared first so it has run
// before the mount and update effects of the same commit.
useEffect(() => {
latest.current = options;
});
useEffect(() => {
let cancelled = false;
loadEngine(src)
.then(() => {
if (cancelled || !root.current || !window.ShapeWaves) return;
handle.current = window.ShapeWaves.mount(root.current, latest.current);
})
.catch((error) => console.error("[ShapeWaves]", error));
return () => {
cancelled = true;
handle.current?.destroy();
handle.current = null;
};
}, [src]);
useEffect(() => {
handle.current?.update(latest.current);
}, [signature]);
useEffect(() => {
if (introKey !== undefined) handle.current?.replay();
}, [introKey]);
return (
<div
ref={root}
className={className}
aria-hidden="true"
style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none", ...style }}
/>
);
}
ShapeWavesDemo.tsx
The demo on this page: the shape picker and the replay button.
"use client";
import { useState } from "react";
import { ShapeWaves, type ShapeWavesOptions } from "@/components/ui/ShapeWaves";
// The demo on zosasounds.com/ui/shape-waves: the field on black the way
// React Bits shows it, a picker for the shape mode, and a replay of the
// intro sweep. Move the pointer over it for the ripples.
const MODES: NonNullable<ShapeWavesOptions["shapes"]>[] = ["mixed", "squares", "circles", "triangles"];
export function ShapeWavesDemo() {
const [shapes, setShapes] = useState<NonNullable<ShapeWavesOptions["shapes"]>>("mixed");
const [introKey, setIntroKey] = useState(0);
return (
<div className="flex w-full flex-col items-center gap-4">
<section
className="relative w-full overflow-hidden rounded-2xl"
style={{ background: "#000", height: "22rem" }}
aria-label="Shape Waves, live"
>
<ShapeWaves shapes={shapes} color="#929292" hoverColor="#ffffff" introKey={introKey} />
<p
className="pointer-events-none absolute inset-x-0 bottom-4 text-center text-[11px] font-semibold uppercase"
style={{ letterSpacing: "0.18em", color: "rgba(255,255,255,.55)" }}
>
Move the pointer over it
</p>
</section>
<div className="flex flex-wrap items-center justify-center gap-2">
<div className="flex gap-1" role="radiogroup" aria-label="Shapes">
{MODES.map((m) => (
<button
key={m}
type="button"
role="radio"
aria-checked={m === shapes}
onClick={() => setShapes(m)}
className="rounded-full border px-3 py-1 text-xs font-medium capitalize transition"
style={{
borderColor: m === shapes ? "currentColor" : "transparent",
background: m === shapes ? "#fff" : "transparent",
}}
>
{m}
</button>
))}
</div>
<button
type="button"
onClick={() => setIntroKey((k) => k + 1)}
className="rounded-full border px-3 py-1 text-xs font-medium transition"
style={{ borderColor: "currentColor" }}
>
Replay intro
</button>
</div>
</div>
);
}