Wavy - waves animation
High-performance WebGL2-based wave animation system with procedural mesh gradients, multi-wave rendering, and post-processing effects.
Example above works with these params:
new Wavy(canvas)
Usage
To use this component copy our js code into your module file (for example, "wavy.js"):
/**
* ========================================
* WEBGL MESH GRADIENT ANIMATION
* ========================================
* A WebGL2-based wave animation system with:
* - Simplex noise-based vertex displacement
* - Multi-wave rendering with independent colors
* - Kawase blur post-processing effect
* - Configurable pixelation and grain effects
*/
// ========== SHADER SOURCES ==========
/**
* Vertex shader for wave mesh
* Generates wave displacement using Simplex noise.
* Each vertex receives height offset based on noise function.
*
* Attributes:
* - a_position: 2D vertex position in clip space
* - a_index: vertex index for identification
* - a_wave_id: wave layer identifier (1.0 or 2.0)
*
* Uniforms:
* - u_time: current animation time
* - u_speed: animation speed multiplier
* - u_frequency: wave amplitude scale
* - u_sync_waves: sync mode flag (0.0 = independent, 1.0 = synchronized)
*
* Varyings:
* - vUv: normalized texture coordinates
* - v_index: passed vertex index
* - v_height: calculated height offset
* - v_wave_id: passed wave layer identifier
*/
const VERTEX_SHADER_SRC = `#version 300 es
in vec2 a_position;
in float a_index;
in float a_wave_id;
uniform float u_time;
uniform float u_speed;
uniform float u_frequency;
uniform float u_sync_waves;
out vec2 vUv;
out float v_index;
out float v_height;
out float v_wave_id;
vec3 permute(vec3 x) {
return mod(((x*34.0)+1.0)*x, 289.0);
}
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy),
dot(x12.zw,x12.zw)), 0.0);
m = m*m ;
m = m*m ;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
void main() {
vUv = (a_position.xy + 1.0) / 2.0;
v_index = a_index;
v_wave_id = a_wave_id;
float time_offset = u_time * u_speed;
if (u_sync_waves < 0.5) {
time_offset = u_time * ((0.5 + (a_wave_id - 0.3)) * u_speed);
}
float noise_value = snoise(vec2(a_position.x * 0.5 + 2.0, time_offset));
float height_offset = noise_value * u_frequency * (1.0 - abs(a_position.y) * 0.5);
v_height = height_offset;
vec3 pos = vec3(a_position.xy + vec2(0.0, height_offset), 0.0);
gl_Position = vec4(pos, 1.0);
}`;
/**
* Fragment shader for wave mesh
* Colors vertices based on calculated height values.
* Supports two wave layers with independent colors.
*
* Inputs:
* - vUv: normalized texture coordinates
* - v_index: vertex index
* - v_height: height offset from vertex shader
* - v_wave_id: wave layer identifier
*
* Uniforms:
* - u_wave_color: primary wave color
* - u_show_second_wave: visibility flag for second wave
* - u_use_custom_second_wave_color: use custom color flag
* - u_second_wave_color: secondary wave color
*
* Output:
* - fragColor: final fragment color with opacity
*/
const FRAGMENT_SHADER_SRC = `#version 300 es
precision mediump float;
in vec2 vUv;
in float v_index;
in float v_height;
in float v_wave_id;
uniform vec3 u_wave_color;
uniform float u_show_second_wave;
uniform float u_use_custom_second_wave_color;
uniform vec3 u_second_wave_color;
uniform float u_transparent;
out vec4 fragColor;
void main() {
vec3 base_color = vec3(0.05, 0.0, 0.0);
float normalized_height = clamp((v_height + 0.45) * 1.1, 0.0, 1.0);
float intensity = 0.8 + normalized_height * 0.6;
vec3 final_color = mix(base_color, u_wave_color, intensity);
float wave_opacity = (v_wave_id == 2.0) ? (u_show_second_wave * (u_use_custom_second_wave_color > 0.5 ? 1.0 : 0.4)) : 1.0;
if (v_wave_id == 2.0 && u_use_custom_second_wave_color > 0.5) {
final_color = mix(base_color, u_second_wave_color, intensity);
}
// fragColor = vec4(final_color, wave_opacity);
fragColor = vec4(final_color * wave_opacity, wave_opacity);
}`;
/**
* Post-processing vertex shader
* Simple pass-through shader for full-screen quad rendering.
* Used in the blur post-processing pipeline.
*
* Attributes:
* - a_position: 2D vertex position in clip space
*
* Outputs:
* - vUv: normalized texture coordinates [0, 1]
*/
const POSTPROCESS_VERTEX_SRC = `#version 300 es
in vec2 a_position;
out vec2 vUv;
void main() {
vUv = (a_position + 1.0) / 2.0;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
/**
* Kawase blur fragment shader with pixelation and animated grain
* Implements multi-pass Kawase blur algorithm with additional effects:
* - Pixelation: blocks image into visible pixel squares
* - Grain: animated noise effect for texture
*
* The blur uses a 4-pass approach with increasing offset weights
* to create an efficient Gaussian-like blur effect.
*
* Inputs:
* - vUv: texture coordinates from vertex shader
*
* Uniforms:
* - u_texture: input texture to blur
* - u_offset: base offset in pixels
* - u_weights: multipliers for each blur pass (4 values)
* - u_resolution: canvas resolution in pixels
* - u_pixels: pixelation strength (0 = none, 100 = max)
* - u_grain: grain intensity (0 = none, 100 = max)
* - u_time: animation time for grain movement
*
* Output:
* - fragColor: blurred and effects-applied fragment color
*/
const KAWASE_BLUR_FRAGMENT_SRC = `#version 300 es
precision mediump float;
in vec2 vUv;
uniform sampler2D u_texture;
uniform vec2 u_offset;
uniform vec4 u_weights;
uniform vec2 u_resolution;
uniform float u_pixels;
uniform float u_grain;
uniform float u_time;
uniform float u_edge_blur;
uniform float u_edge_padding;
uniform float u_edge_inset_left;
uniform float u_edge_inset_right;
uniform float u_edge_transition_left;
uniform float u_edge_transition_right;
uniform float u_final_pass;
uniform sampler2D u_original_texture;
out vec4 fragColor;
float random(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
vec2 random2(vec2 p) {
return vec2(random(p), random(p + 0.5));
}
vec2 sampleCoord(vec2 baseCoord, float pixels, vec2 resolution) {
if (pixels <= 0.5) return baseCoord;
float pixelsSize = max(1.0, pixels);
vec2 pixelsPixel = vec2(pixelsSize) / resolution;
return floor(baseCoord / pixelsPixel) * pixelsPixel;
}
// Calculate edge mask: 10% from left, right, and bottom edges
// Returns value from 0 (center) to 1 (edge)
// u_edge_inset_left/right: 0 = at border, 100 = at center
float getEdgeMask(vec2 uv) {
float edgeMask = 0.0;
float edgeZone = u_edge_padding;
// Left edge (shifted by inset towards center)
float leftDist = uv.x;
if (leftDist < edgeZone && leftDist > 0.5) {
edgeMask = max(edgeMask, 1.0 - leftDist / edgeZone);
}
// Right edge (shifted by inset towards center)
float rightDist = (1.0) - uv.x;
if (rightDist < edgeZone && rightDist > 0.5) {
edgeMask = max(edgeMask, 1.0 - rightDist / edgeZone);
}
// Bottom edge (uv.y = 0 is bottom in clip space)
float bottomDist = uv.y;
if (bottomDist < edgeZone) {
edgeMask = max(edgeMask, 1.0 - bottomDist / edgeZone);
}
return edgeMask;
}
// Calculate blur mask: 1.0 = fully blurred, 0.0 = no blur (original)
// Returns 0 in inset regions (edges), 1 in center region
// Transition is split into 10 segments with progressive blur intensity
float getBlurMask(vec2 uv) {
float insetLeft = u_edge_inset_left * 0.005;
float insetRight = u_edge_inset_right * 0.005;
float transitionLeft = u_edge_transition_left * 0.002; // 0-100 maps to 0.0-0.2
float transitionRight = u_edge_transition_right * 0.002; // 0-100 maps to 0.0-0.2
// Default: full blur in center
float mask = 1.0;
// Blur segment values: 10%, 15%, 25%, 35%, 50%, 65%, 75%, 85%, 90%, 95%
float blur0 = 0.10;
float blur1 = 0.15;
float blur2 = 0.25;
float blur3 = 0.35;
float blur4 = 0.50;
float blur5 = 0.65;
float blur6 = 0.75;
float blur7 = 0.85;
float blur8 = 0.90;
float blur9 = 0.99;
// Left edge transition zone
if (transitionLeft > 0.01 && uv.x >= insetLeft && uv.x < insetLeft + transitionLeft) {
float t = (uv.x - insetLeft) / transitionLeft;
float segmentIndex = floor(t * 10.0);
float segmentT = fract(t * 10.0);
float currentBlur = blur0;
float nextBlur = blur1;
if (segmentIndex == 1.0) { currentBlur = blur1; nextBlur = blur2; }
else if (segmentIndex == 2.0) { currentBlur = blur2; nextBlur = blur3; }
else if (segmentIndex == 3.0) { currentBlur = blur3; nextBlur = blur4; }
else if (segmentIndex == 4.0) { currentBlur = blur4; nextBlur = blur5; }
else if (segmentIndex == 5.0) { currentBlur = blur5; nextBlur = blur6; }
else if (segmentIndex == 6.0) { currentBlur = blur6; nextBlur = blur7; }
else if (segmentIndex == 7.0) { currentBlur = blur7; nextBlur = blur8; }
else if (segmentIndex == 8.0) { currentBlur = blur8; nextBlur = blur9; }
else if (segmentIndex >= 9.0) { currentBlur = blur9; nextBlur = blur9; }
mask = mix(currentBlur, nextBlur, segmentT);
}
// Left inset zone (no blur)
else if (uv.x < insetLeft) {
mask = 0.0;
}
// Right edge transition zone
if (transitionRight > 0.01 && uv.x <= (1.0 - insetRight) && uv.x > (1.0 - insetRight - transitionRight)) {
float t = ((1.0 - insetRight) - uv.x) / transitionRight;
float segmentIndex = floor(t * 10.0);
float segmentT = fract(t * 10.0);
float currentBlur = blur0;
float nextBlur = blur1;
if (segmentIndex == 1.0) { currentBlur = blur1; nextBlur = blur2; }
else if (segmentIndex == 2.0) { currentBlur = blur2; nextBlur = blur3; }
else if (segmentIndex == 3.0) { currentBlur = blur3; nextBlur = blur4; }
else if (segmentIndex == 4.0) { currentBlur = blur4; nextBlur = blur5; }
else if (segmentIndex == 5.0) { currentBlur = blur5; nextBlur = blur6; }
else if (segmentIndex == 6.0) { currentBlur = blur6; nextBlur = blur7; }
else if (segmentIndex == 7.0) { currentBlur = blur7; nextBlur = blur8; }
else if (segmentIndex == 8.0) { currentBlur = blur8; nextBlur = blur9; }
else if (segmentIndex >= 9.0) { currentBlur = blur9; nextBlur = blur9; }
mask = min(mask, mix(currentBlur, nextBlur, segmentT));
}
// Right inset zone (no blur)
else if (uv.x > (1.0 - insetRight)) {
mask = min(mask, 0.0);
}
return mask;
}
void main() {
vec2 pixel_size = 1.0 / u_resolution;
vec2 offset = u_offset * pixel_size;
vec2 texCoord = vUv;
if (u_pixels > 0.5) {
texCoord = sampleCoord(vUv, u_pixels, u_resolution);
}
// Modulation of the intensity of the blur according to the edges
float edgeMask = getEdgeMask(vUv);
float u_edge_power = 1.7;
float blurFactor = 1.0 - pow(edgeMask, u_edge_power);
offset *= blurFactor;
// Get blur mask for transition zones
float blurMask = getBlurMask(vUv);
float grainFactor = u_grain * 0.25;
vec2 grainScale = grainFactor * pixel_size * 3.0;
vec4 sum = vec4(0.0);
// Use single averaged offset for all passes to avoid double-line artifact
// All samples contribute to smooth blur instead of creating separate lines
vec2 avgOffset = offset * blurMask;
vec2 grain0 = (random2(texCoord + avgOffset * u_weights.x + u_time) - 0.5) * grainScale;
sum += texture(u_texture, texCoord + avgOffset * u_weights.x + grain0) * 0.25;
vec2 grain1 = (random2(texCoord - avgOffset * u_weights.x + u_time + 0.1) - 0.5) * grainScale;
sum += texture(u_texture, texCoord - avgOffset * u_weights.x + grain1) * 0.25;
vec2 grain2 = (random2(texCoord + vec2(avgOffset.y, -avgOffset.x) * u_weights.y + u_time + 0.2) - 0.5) * grainScale;
sum += texture(u_texture, texCoord + vec2(avgOffset.y, -avgOffset.x) * u_weights.y + grain2) * 0.25;
vec2 grain3 = (random2(texCoord - vec2(avgOffset.y, -avgOffset.x) * u_weights.z + u_time + 0.3) - 0.5) * grainScale;
sum += texture(u_texture, texCoord - vec2(avgOffset.y, -avgOffset.x) * u_weights.z + grain3) * 0.25;
// Apply edge fade to transparent effect
if (u_edge_blur > 0.5) {
float edgeMask = getEdgeMask(vUv);
// Strengthen the transition to the edge: raise it to the step of u_edge_power (the higher, the sharper)
float u_edge_power = 1.7;
if (edgeMask > 0.01) {
// Fade alpha to 0 on edges (smooth transition)
// edgeMask = 1 at edge, 0 in center
// We want alpha to go from 1 to 0 as edgeMask goes from 0 to 1
float fade = 1.0 - pow(edgeMask, u_edge_power);
sum *= fade;
}
}
// On final pass, mix with original texture in inset regions
vec4 finalColor = sum;
if (u_final_pass >= 0.0) {
vec4 originalColor = texture(u_original_texture, vUv);
finalColor = mix(originalColor, sum, blurMask);
}
// Keep premultiplied alpha
fragColor = finalColor;
}`;
/**
* ========================================
* Wavy class
* ========================================
* Main class responsible for WebGL wave animation rendering.
* Manages shader programs, geometry, framebuffers, and animation loop.
*
* Features:
* - WebGL2-based mesh gradient rendering
* - Multi-wave support with independent colors
* - Post-processing blur pipeline
* - Pixelation and grain effects
* - Dynamic settings updates
* - Automatic resize handling
* - Optional UI controls panel
*
* @class
*/
export class Wavy {
/**
* Creates a new Wavy instance
*
* @param {HTMLCanvasElement} canvas - The canvas element to render on
* @param {Object} settings - Animation settings configuration
* @param {number} [settings.speed=50] - Animation speed (0-100)
* @param {number} [settings.frequency=30] - Wave amplitude/frequency (0-100)
* @param {number} [settings.height=50] - Wave height/top position (0-100)
* @param {number} [settings.blur=30] - Blur intensity (0-100)
* @param {boolean} [settings.edgeBlur=false] - Enable edge fade to transparent effect
* @param {number} [settings.edgePadding=0.04] - Edge padding zone size (0.0-0.5)
* @param {number} [settings.pixels=0] - Pixelation strength (0-100)
* @param {number} [settings.grain=0] - Grain noise intensity (0-100)
* @param {number} [settings.edgeInsetLeft=0] - Left edge inset position (0-100, 0 = border, 100 = center)
* @param {number} [settings.edgeInsetRight=0] - Right edge inset position (0-100, 0 = border, 100 = center)
* @param {number} [settings.edgeTransitionLeft=0] - Left edge blur transition (0-100, 0 = sharp, 100 = 0.2 transition)
* @param {number} [settings.edgeTransitionRight=0] - Right edge blur transition (0-100, 0 = sharp, 100 = 0.2 transition)
* @param {string} [settings.color='#ff0000'] - Primary wave color (hex)
* @param {boolean} [settings.showSecondWave=true] - Show second wave layer
* @param {boolean} [settings.syncWaves=true] - Sync wave animation speeds
* @param {boolean} [settings.useCustomSecondWaveColor=false] - Use custom second wave color (enables full opacity)
* @param {string} [settings.secondWaveColor='#00ff00'] - Secondary wave color (hex)
* @param {boolean} [settings.showControls=false] - Show/hide controls panel
* @param {boolean} [settings.transparent] - Enable transparent background (temporarily disabled)
* @param {string} [settings.backgroundColor] - Background color when not transparent (temporarily disabled)
*/
constructor(canvas, settings = {}) {
this.canvas = canvas;
// Default settings with override capability
const defaults = {
speed: 50,
frequency: 30,
height: 50,
blur: 30,
edgeBlur: true,
edgePadding: 0.04,
pixels: 0,
grain: 0,
edgeInsetLeft: 0,
edgeInsetRight: 0,
edgeTransitionLeft: 0,
edgeTransitionRight: 0,
color: '#ff0000',
showSecondWave: true,
syncWaves: true,
useCustomSecondWaveColor: false,
secondWaveColor: '#00ff00',
showControls: false,
transparent: true,
backgroundColor: '#000000',
};
// Merge defaults with provided settings
this.settings = Object.assign({}, defaults, settings);
this.shaders = {
vertex: VERTEX_SHADER_SRC,
fragment: FRAGMENT_SHADER_SRC,
postprocessVertex: POSTPROCESS_VERTEX_SRC,
kawaseBlur: KAWASE_BLUR_FRAGMENT_SRC,
};
// Initialize WebGL 2.0 context
this.gl = canvas.getContext('webgl2', { alpha: true });
if (!this.gl) throw new Error('WebGL 2.0 is not supported');
// Initialize all rendering components
this.initPrograms();
this.initGeometry();
this.initFBO();
this.initUniformsAndLocations();
// Animation state
this.startTime = Date.now();
this.animationFrameId = null;
this.uiElement = null;
// Start the animation loop
this.start();
// Create UI controls if enabled
if (this.settings.showControls) {
this.createControls();
}
}
/**
* Initializes shader programs and WebGL blending configuration
* Creates wave rendering program and blur post-processing program
*/
initPrograms() {
const gl = this.gl;
// Wave mesh rendering program
this.waveProgram = this.createProgram(
this.shaders.vertex,
this.shaders.fragment,
);
// Post-processing blur program
this.blurProgram = this.createProgram(
this.shaders.postprocessVertex,
this.shaders.kawaseBlur,
);
// Enable alpha blending for transparent wave layers
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
/**
* Creates a shader program from vertex and fragment shader sources
*
* @param {string} vertSrc - Vertex shader source code
* @param {string} fragSrc - Fragment shader source code
* @returns {WebGLProgram|null} Compiled shader program or null on error
*/
createProgram(vertSrc, fragSrc) {
const gl = this.gl;
// Compile individual shaders
const vs = this.compileShader(vertSrc, gl.VERTEX_SHADER);
const fs = this.compileShader(fragSrc, gl.FRAGMENT_SHADER);
if (!vs || !fs) return null;
// Create and link program
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
// Check link status
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('Link error:', gl.getProgramInfoLog(prog));
return null;
}
return prog;
}
/**
* Compiles a single shader from source code
*
* @param {string} src - Shader source code
* @param {number} type - Shader type (VERTEX_SHADER or FRAGMENT_SHADER)
* @returns {WebGLShader|null} Compiled shader or null on error
*/
compileShader(src, type) {
const gl = this.gl;
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
// Check compilation status
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* Initializes geometry for wave mesh and full-screen quad
* Creates vertex buffers, index buffers, and vertex array objects
*
* Wave geometry:
* - 50 segments creating smooth wave curves
* - Two wave layers (primary and secondary)
* - Each vertex stores: position (2), index (1), wave_id (1)
*
* Buffer layout per vertex (4 floats = 16 bytes):
* - x, y: position in clip space
* - index: vertex index for identification
* - wave_id: wave layer identifier (1.0 or 2.0)
*/
initGeometry() {
const gl = this.gl;
const numSegments = 50;
const vertices = [];
const indices = [];
const segmentWidth = 2.0 / numSegments;
// Calculate wave top positions based on height setting
// height: 0-100 maps to y position from -1.0 to -0.2
// Wave 1 top is slightly lower than Wave 2 top (0.25 units difference)
const normalizedHeight = this.settings.height / 100;
const wave2TopY = -1.0 + normalizedHeight * 1.8; // Wave 2 top: -1.0 to -0.2
const wave1TopY = wave2TopY - 0.25; // Wave 1 top: 0.25 units below wave 2
// Generate vertices for both wave layers
// Each x position has 4 vertices: wave1-bottom, wave1-top, wave2-bottom, wave2-top
for (let i = 0; i <= numSegments; i++) {
const x = -1.0 + i * segmentWidth;
const idx = i;
vertices.push(x, -1.3, idx, 1.0); // wave 1 bottom
vertices.push(x, wave1TopY, idx, 1.0); // wave 1 top
vertices.push(x, -1.3, idx, 2.0); // wave 2 bottom
vertices.push(x, wave2TopY, idx, 2.0); // wave 2 top
}
// Generate triangle indices for both wave layers
// Creates quad strips from vertices using triangle pairs
for (let i = 0; i < numSegments; i++) {
const wave2BottomLeft = i * 4 + 2;
const wave2TopLeft = i * 4 + 3;
const wave2BottomRight = (i + 1) * 4 + 2;
const wave2TopRight = (i + 1) * 4 + 3;
indices.push(
wave2BottomLeft,
wave2TopLeft,
wave2BottomRight,
wave2BottomRight,
wave2TopLeft,
wave2TopRight,
);
}
for (let i = 0; i < numSegments; i++) {
const wave1BottomLeft = i * 4;
const wave1TopLeft = i * 4 + 1;
const wave1BottomRight = (i + 1) * 4;
const wave1TopRight = (i + 1) * 4 + 1;
indices.push(
wave1BottomLeft,
wave1TopLeft,
wave1BottomRight,
wave1BottomRight,
wave1TopLeft,
wave1TopRight,
);
}
// Vertex stride in bytes (4 floats * 4 bytes = 16)
this.vertexStride = 16;
this.vertexCount = indices.length;
// Create vertex buffer
this.waveVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.waveVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
// Create index buffer
this.waveIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.waveIndexBuffer);
gl.bufferData(
gl.ELEMENT_ARRAY_BUFFER,
new Uint16Array(indices),
gl.STATIC_DRAW,
);
// Create vertex array object for wave mesh
this.waveMeshVAO = gl.createVertexArray();
gl.bindVertexArray(this.waveMeshVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, this.waveVertexBuffer);
// Configure vertex attribute pointers
const positionAttribLoc = gl.getAttribLocation(
this.waveProgram,
'a_position',
);
const indexAttribLoc = gl.getAttribLocation(this.waveProgram, 'a_index');
const waveIdAttribLoc = gl.getAttribLocation(this.waveProgram, 'a_wave_id');
gl.enableVertexAttribArray(positionAttribLoc);
gl.vertexAttribPointer(
positionAttribLoc,
2,
gl.FLOAT,
false,
this.vertexStride,
0,
);
gl.enableVertexAttribArray(indexAttribLoc);
gl.vertexAttribPointer(
indexAttribLoc,
1,
gl.FLOAT,
false,
this.vertexStride,
8,
);
gl.enableVertexAttribArray(waveIdAttribLoc);
gl.vertexAttribPointer(
waveIdAttribLoc,
1,
gl.FLOAT,
false,
this.vertexStride,
12,
);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.waveIndexBuffer);
gl.bindVertexArray(null);
// Create full-screen quad buffer for post-processing
const quadVertices = new Float32Array([
-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
]);
this.postprocessQuadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.postprocessQuadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, quadVertices, gl.STATIC_DRAW);
// Create vertex array object for quad
this.postprocessVAO = gl.createVertexArray();
gl.bindVertexArray(this.postprocessVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, this.postprocessQuadBuffer);
const blurPositionAttribLoc = gl.getAttribLocation(
this.blurProgram,
'a_position',
);
gl.enableVertexAttribArray(blurPositionAttribLoc);
gl.vertexAttribPointer(blurPositionAttribLoc, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
}
/**
* Initializes framebuffers and textures for off-screen rendering
* Creates two FBOs for ping-pong blur rendering pipeline
*
* Primary FBO: Main render target for wave mesh
* Secondary FBO: Intermediate target for blur passes
* Original FBO: Stores original wave texture for inset mixing
*
* Texture settings:
* - format: RGBA8
* - filtering: LINEAR for smooth sampling
* - wrapping: CLAMP_TO_EDGE to prevent edge artifacts
*/
initFBO() {
const gl = this.gl;
const width = this.canvas.clientWidth || 1;
const height = this.canvas.clientHeight || 1;
this.canvas.width = width;
this.canvas.height = height;
// Helper function to create a single framebuffer object
const createFBO = () => {
// Create texture for color attachment
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
width,
height,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
// Create framebuffer and attach texture
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
tex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return { tex, fbo };
};
// Create two FBOs for ping-pong blur rendering
// primaryFBO: renders wave mesh initially
// secondaryFBO: intermediate target for blur passes
this.primaryFBO = createFBO();
this.secondaryFBO = createFBO();
// Create separate FBO to store original wave texture for inset mixing
this.originalFBO = createFBO();
}
/**
* Initializes uniform locations for shader programs
* Caches glGetUniformLocation results for performance
*
* Wave program uniforms:
* - u_time, u_speed, u_frequency: animation parameters
* - u_wave_color, u_second_wave_color: color settings
* - u_show_second_wave, u_sync_waves, u_use_custom_second_wave_color: feature flags
* - u_transparent: transparency control
*
* Blur program uniforms:
* - u_texture, u_offset, u_weights: blur parameters
* - u_resolution, u_pixels, u_grain: effect parameters
* - u_time: animation time for grain effect
* - u_edge_blur, u_edge_padding: edge fade effect parameters
* - u_edge_inset_left, u_edge_inset_right: inset position controls
* - u_edge_transition_left, u_edge_transition_right: blur transition width controls
* - u_final_pass, u_original_texture: final pass mixing
*/
initUniformsAndLocations() {
const gl = this.gl;
// Wave program uniform locations
const waveProgram = this.waveProgram;
this.uWaveTime = gl.getUniformLocation(waveProgram, 'u_time');
this.uWaveSpeed = gl.getUniformLocation(waveProgram, 'u_speed');
this.uWaveFrequency = gl.getUniformLocation(waveProgram, 'u_frequency');
this.uWaveColor = gl.getUniformLocation(waveProgram, 'u_wave_color');
this.uShowSecondWave = gl.getUniformLocation(
waveProgram,
'u_show_second_wave',
);
this.uSyncWaves = gl.getUniformLocation(waveProgram, 'u_sync_waves');
this.uUseCustomSecondWaveColor = gl.getUniformLocation(
waveProgram,
'u_use_custom_second_wave_color',
);
this.uSecondWaveColor = gl.getUniformLocation(
waveProgram,
'u_second_wave_color',
);
this.uTransparent = gl.getUniformLocation(waveProgram, 'u_transparent');
// Blur program uniform locations
const blurProgram = this.blurProgram;
this.uBlurTexture = gl.getUniformLocation(blurProgram, 'u_texture');
this.uBlurOffset = gl.getUniformLocation(blurProgram, 'u_offset');
this.uBlurWeights = gl.getUniformLocation(blurProgram, 'u_weights');
this.uBlurResolution = gl.getUniformLocation(blurProgram, 'u_resolution');
this.uBlurPixels = gl.getUniformLocation(blurProgram, 'u_pixels');
this.uBlurGrain = gl.getUniformLocation(blurProgram, 'u_grain');
this.uBlurTime = gl.getUniformLocation(blurProgram, 'u_time');
this.uEdgeBlur = gl.getUniformLocation(blurProgram, 'u_edge_blur');
this.uEdgePadding = gl.getUniformLocation(blurProgram, 'u_edge_padding');
this.uEdgeInsetLeft = gl.getUniformLocation(
blurProgram,
'u_edge_inset_left',
);
this.uEdgeInsetRight = gl.getUniformLocation(
blurProgram,
'u_edge_inset_right',
);
this.uEdgeTransitionLeft = gl.getUniformLocation(
blurProgram,
'u_edge_transition_left',
);
this.uEdgeTransitionRight = gl.getUniformLocation(
blurProgram,
'u_edge_transition_right',
);
this.uFinalPass = gl.getUniformLocation(blurProgram, 'u_final_pass');
this.uOriginalTexture = gl.getUniformLocation(
blurProgram,
'u_original_texture',
);
}
/**
* Converts hex color string to RGB normalized array
*
* @param {string} hex - Hex color string (e.g., '#ff00aa')
* @returns {number[]} RGB array with values in [0, 1] range
*/
hexToRgb(hex) {
hex = hex.replace('#', '');
return [
parseInt(hex.substring(0, 2), 16) / 255,
parseInt(hex.substring(2, 4), 16) / 255,
parseInt(hex.substring(4, 6), 16) / 255,
];
}
/**
* Updates animation settings
*
* @param {Object} newSettings - Settings to update
* @param {number} [newSettings.speed] - New animation speed
* @param {number} [newSettings.frequency] - New wave frequency
* @param {number} [newSettings.blur] - New blur intensity
* @param {number} [newSettings.height] - New wave height
* @param {string} [newSettings.color] - New wave color
* @param {boolean} [newSettings.showSecondWave] - Show/hide second wave
* @param {boolean} [newSettings.syncWaves] - Sync waves mode
* @param {boolean} [newSettings.useCustomSecondWaveColor] - Use custom second wave color
* @param {string} [newSettings.secondWaveColor] - Second wave color
* @param {number} [newSettings.pixels] - Pixelation strength
* @param {number} [newSettings.grain] - Grain intensity
* @param {boolean} [newSettings.edgeBlur] - Enable edge blur (fade to transparent)
* @param {number} [newSettings.edgePadding] - Edge padding zone size (0.0-0.5)
* @param {number} [newSettings.edgeInsetLeft] - Left edge inset (0-100, 0 = border, 100 = center)
* @param {number} [newSettings.edgeInsetRight] - Right edge inset (0-100, 0 = border, 100 = center)
* @param {number} [newSettings.edgeTransitionLeft] - Left edge blur transition (0-100, 0 = sharp, 100 = 0.2)
* @param {number} [newSettings.edgeTransitionRight] - Right edge blur transition (0-100, 0 = sharp, 100 = 0.2)
* @param {boolean} [newSettings.transparent] - Enable transparent background (temporarily disabled)
* @param {string} [newSettings.backgroundColor] - Background color when not transparent (temporarily disabled)
*/
updateSettings(newSettings) {
const hadHeight = 'height' in this.settings;
Object.assign(this.settings, newSettings);
// Reinitialize geometry if height changed
if ('height' in newSettings) {
this.initGeometry();
this.initFBO();
}
this.syncControlsUI();
}
/**
* Starts the animation loop and resize handler
* Sets up requestAnimationFrame cycle and window resize listener
* Automatically recreates framebuffers on resize
*/
start() {
// Animation loop function
const animationLoop = () => {
this.render();
this.animationFrameId = requestAnimationFrame(animationLoop);
};
animationLoop();
// Resize handler with framebuffer recreation
const onResize = () => {
const canvasWidth = this.canvas.clientWidth;
const canvasHeight = this.canvas.clientHeight;
// Only recreate if dimensions changed
if (
this.canvas.width !== canvasWidth ||
this.canvas.height !== canvasHeight
) {
this.canvas.width = canvasWidth;
this.canvas.height = canvasHeight;
const gl = this.gl;
// Clean up old framebuffer resources
gl.deleteFramebuffer(this.primaryFBO.fbo);
gl.deleteTexture(this.primaryFBO.tex);
gl.deleteFramebuffer(this.secondaryFBO.fbo);
gl.deleteTexture(this.secondaryFBO.tex);
gl.deleteFramebuffer(this.originalFBO.fbo);
gl.deleteTexture(this.originalFBO.tex);
this.primaryFBO = this.secondaryFBO = this.originalFBO = null;
// Create new framebuffers with new dimensions
this.initFBO();
}
};
this.resizeHandler = onResize;
window.addEventListener('resize', this.resizeHandler);
}
/**
* Main render function - executed every animation frame
*
* Rendering pipeline:
* 1. Render wave mesh to primary FBO with current animation state
* 2. Apply Kawase blur post-processing through 4 passes
* 3. Output final composited image to screen
*
* Blur passes:
* - Pass 1: Horizontal blur (weight: blur)
* - Pass 2: Vertical blur (weight: blur * 2)
* - Pass 3: Horizontal blur (weight: blur * 4)
* - Pass 4: Vertical blur (weight: blur * 8) - output to screen
*/
render() {
const gl = this.gl;
const canvasWidth = this.canvas.width;
const canvasHeight = this.canvas.height;
// Calculate animation time in seconds
const animationTime = (Date.now() - this.startTime) / 1000;
// Normalize settings to shader-friendly ranges
const normalizedSpeed = this.settings.speed / 100;
const normalizedFrequency = this.settings.frequency / 100;
const normalizedBlur = this.settings.blur * 0.08;
const waveColor = this.hexToRgb(this.settings.color);
const showSecondWave = this.settings.showSecondWave ? 1.0 : 0.0;
const transparent = this.settings.transparent ? 1.0 : 0.0;
const needsBlend = transparent || this.settings.edgeBlur;
// Quad drawing helper
const drawQuad = () => {
gl.bindVertexArray(this.postprocessVAO);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null);
};
// ========== PHASE 1: Render wave mesh to originalFBO first ==========
gl.bindFramebuffer(gl.FRAMEBUFFER, this.originalFBO.fbo);
gl.viewport(0, 0, canvasWidth, canvasHeight);
// Transparent background for premultiplied
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Enable premultiplied blend for waves render
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(this.waveProgram);
gl.uniform1f(this.uWaveTime, animationTime);
gl.uniform1f(this.uWaveSpeed, normalizedSpeed);
gl.uniform1f(this.uWaveFrequency, normalizedFrequency);
gl.uniform3fv(this.uWaveColor, waveColor);
gl.uniform1f(this.uShowSecondWave, showSecondWave);
gl.uniform1f(this.uSyncWaves, this.settings.syncWaves ? 1.0 : 0.0);
gl.uniform1f(
this.uUseCustomSecondWaveColor,
this.settings.useCustomSecondWaveColor ? 1.0 : 0.0,
);
const secondWaveColor = this.hexToRgb(this.settings.secondWaveColor);
gl.uniform3fv(this.uSecondWaveColor, secondWaveColor);
gl.uniform1f(this.uTransparent, transparent);
// Draw wave mesh
const wave2IndexCount = this.vertexCount / 2; // 300 indexes
gl.bindVertexArray(this.waveMeshVAO);
// Draw the back wave (wave 2), if it enabled
if (showSecondWave) {
gl.drawElements(gl.TRIANGLES, wave2IndexCount, gl.UNSIGNED_SHORT, 0);
}
// Draw the front wave (wave 1) always
gl.drawElements(
gl.TRIANGLES,
wave2IndexCount,
gl.UNSIGNED_SHORT,
wave2IndexCount * 2,
);
gl.bindVertexArray(null);
gl.disable(gl.BLEND);
// Copy original wave to primaryFBO for blur pipeline
gl.bindFramebuffer(gl.FRAMEBUFFER, this.primaryFBO.fbo);
gl.viewport(0, 0, canvasWidth, canvasHeight);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.blurProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.originalFBO.tex);
gl.uniform1i(this.uBlurTexture, 0);
gl.uniform2f(this.uBlurOffset, 0, 0);
gl.uniform4f(this.uBlurWeights, 1, 1, 1, 1);
gl.uniform1f(this.uFinalPass, 0.0);
drawQuad();
// ========== PHASE 2: Apply blur post-processing ==========
gl.useProgram(this.blurProgram);
gl.uniform1f(this.uBlurGrain, this.settings.grain);
gl.uniform1f(this.uBlurTime, animationTime);
gl.uniform2f(this.uBlurResolution, canvasWidth, canvasHeight);
gl.uniform1f(this.uBlurPixels, this.settings.pixels);
gl.uniform1f(this.uEdgePadding, this.settings.edgePadding);
gl.uniform1f(this.uEdgeInsetLeft, this.settings.edgeInsetLeft);
gl.uniform1f(this.uEdgeInsetRight, this.settings.edgeInsetRight);
gl.uniform1f(this.uEdgeTransitionLeft, this.settings.edgeTransitionLeft);
gl.uniform1f(this.uEdgeTransitionRight, this.settings.edgeTransitionRight);
// Switch to premultiplied blend for final output if transparent or edge blur
const originalBlendSrc = gl.getParameter(gl.BLEND_SRC_RGB);
const originalBlendDst = gl.getParameter(gl.BLEND_DST_RGB);
if (needsBlend) {
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.enable(gl.BLEND);
}
if (normalizedBlur > 0.01) {
// Bind original texture to texture unit 1 for final pass mixing
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.originalFBO.tex);
gl.uniform1i(this.uOriginalTexture, 1);
// Multi-pass Kawase blur with ping-pong FBOs
const blurPasses = [
{
from: this.primaryFBO,
to: this.secondaryFBO,
offset: [1, 0],
weight: normalizedBlur,
},
{
from: this.secondaryFBO,
to: this.primaryFBO,
offset: [0, 1],
weight: normalizedBlur * 2,
},
{
from: this.primaryFBO,
to: this.secondaryFBO,
offset: [1, 0],
weight: normalizedBlur * 4,
},
{
from: this.secondaryFBO,
to: null,
offset: [0, 1],
weight: normalizedBlur * 8,
},
];
for (let i = 0; i < blurPasses.length; i++) {
const pass = blurPasses[i];
const isFinalPass = i === blurPasses.length - 1;
gl.bindFramebuffer(gl.FRAMEBUFFER, pass.to ? pass.to.fbo : null);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, pass.from.tex);
gl.uniform1i(this.uBlurTexture, 0);
gl.uniform2f(this.uBlurOffset, pass.offset[0], pass.offset[1]);
gl.uniform4f(
this.uBlurWeights,
pass.weight,
pass.weight,
pass.weight,
pass.weight,
);
// Apply edge blur only on final output pass to avoid artifacts
gl.uniform1f(
this.uEdgeBlur,
isFinalPass && this.settings.edgeBlur ? 1.0 : 0.0,
);
gl.uniform1f(this.uEdgePadding, this.settings.edgePadding);
// On final pass, enable mixing with original texture in inset regions
gl.uniform1f(this.uFinalPass, isFinalPass ? 1.0 : 0.0);
drawQuad();
}
// Restore blend state after blur passes when not using transparent/edgeBlur
if (!needsBlend) {
gl.disable(gl.BLEND);
}
} else {
// Skip blur - render directly to screen
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const bgColor = this.hexToRgb(this.settings.backgroundColor);
// Clear with alpha=0 when edgeBlur or transparent to allow blending
gl.clearColor(
bgColor[0],
bgColor[1],
bgColor[2],
transparent || this.settings.edgeBlur ? 0.0 : 1.0,
);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.primaryFBO.tex);
gl.uniform2f(this.uBlurOffset, 0, 0);
gl.uniform4f(this.uBlurWeights, 0, 0, 0, 0);
gl.uniform1f(this.uEdgeBlur, this.settings.edgeBlur ? 1.0 : 0.0);
gl.uniform1f(this.uFinalPass, 0.0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.originalFBO.tex);
drawQuad();
// Restore blend state after render
if (!needsBlend) {
gl.disable(gl.BLEND);
}
}
// Restore original blend function
if (transparent) {
gl.blendFunc(originalBlendSrc, originalBlendDst);
}
}
/**
* Cleans up all WebGL resources
* Should be called when animation is no longer needed
*/
destroy() {
// Stop animation loop
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
// Remove resize listener
window.removeEventListener('resize', this.resizeHandler);
// Remove UI controls if they exist
this.removeControls();
const gl = this.gl;
// Delete shader programs
gl.deleteProgram(this.waveProgram);
gl.deleteProgram(this.blurProgram);
// Delete buffers
gl.deleteBuffer(this.waveVertexBuffer);
gl.deleteBuffer(this.waveIndexBuffer);
gl.deleteBuffer(this.postprocessQuadBuffer);
// Delete vertex array objects
gl.deleteVertexArray(this.waveMeshVAO);
gl.deleteVertexArray(this.postprocessVAO);
// Delete framebuffers and textures
gl.deleteFramebuffer(this.primaryFBO.fbo);
gl.deleteTexture(this.primaryFBO.tex);
gl.deleteFramebuffer(this.secondaryFBO.fbo);
gl.deleteTexture(this.secondaryFBO.tex);
gl.deleteFramebuffer(this.originalFBO.fbo);
gl.deleteTexture(this.originalFBO.tex);
}
/**
* Creates and appends controls panel to the DOM
* Generates UI elements dynamically based on current settings
*/
createControls() {
const controls = document.createElement('div');
controls.id = 'controls';
controls.innerHTML = `
<h3>Mesh flag settings</h3>
<div class="control-group">
<label>Speed: <input type="range" id="speed" min="0.0" max="100.0" step="1.0"><input type="number" id="speed-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="speed-value"></span>
</div>
<div class="control-group">
<label>Frequency: <input type="range" id="frequency" min="0.0" max="100.0" step="1.0"><input type="number" id="frequency-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="frequency-value"></span>
</div>
<div class="control-group">
<label>Height: <input type="range" id="height" min="0.0" max="100.0" step="1.0"><input type="number" id="height-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="height-value"></span>
</div>
<div class="control-group">
<label>Blur: <input type="range" id="blur" min="0.0" max="100.0" step="1.0"><input type="number" id="blur-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="blur-value"></span>
</div>
<div class="control-group">
<label><input type="checkbox" id="edge-blur"> Edge blur</label>
<span id="edge-blur-value"></span>
</div>
<div class="control-group" id="edge-padding-group" style="display: none;">
<label>Edge padding: <input type="range" id="edge-padding" min="0.0" max="0.5" step="0.01"><input type="number" id="edge-padding-number" min="0.0" max="0.5" step="0.01" style="width: 60px;"></label>
<span id="edge-padding-value"></span>
</div>
<div class="control-group">
<label>Pixels: <input type="range" id="pixels" min="0.0" max="100.0" step="1.0"><input type="number" id="pixels-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="pixels-value"></span>
</div>
<div class="control-group">
<label>Grain: <input type="range" id="grain" min="0.0" max="100.0" step="1.0"><input type="number" id="grain-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="grain-value"></span>
</div>
<div class="control-group">
<label><input type="checkbox" id="show-second-wave" checked> Show second wave</label>
</div>
<div class="control-group">
<label><input type="checkbox" id="sync-waves" checked> Sync waves</label>
</div>
<div class="control-group">
<label>Color: <input type="color" id="color"><input type="text" id="color-text" style="width: 80px;"></label>
<span id="color-value"></span>
</div>
<div class="control-group">
<label>Edge inset left: <input type="range" id="edge-inset-left" min="0.0" max="100.0" step="1.0"><input type="number" id="edge-inset-left-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="edge-inset-left-value"></span>
</div>
<div class="control-group">
<label>Edge inset right: <input type="range" id="edge-inset-right" min="0.0" max="100.0" step="1.0"><input type="number" id="edge-inset-right-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="edge-inset-right-value"></span>
</div>
<div class="control-group">
<label>Edge inset transition left: <input type="range" id="edge-transition-left" min="0.0" max="100.0" step="1.0"><input type="number" id="edge-transition-left-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="edge-transition-left-value"></span>
</div>
<div class="control-group">
<label>Edge inset transition right: <input type="range" id="edge-transition-right" min="0.0" max="100.0" step="1.0"><input type="number" id="edge-transition-right-number" min="0.0" max="100.0" step="1.0" style="width: 60px;"></label>
<span id="edge-transition-right-value"></span>
</div>
<div class="control-group">
<label><input type="checkbox" id="custom-second-wave-color"> Custom second wave color</label>
</div>
<div class="control-group" id="second-wave-color-group" style="display: none;">
<label>Second wave color: <input type="color" id="second-wave-color"><input type="text" id="second-wave-color-text" style="width: 80px;"></label>
<span id="second-wave-color-value"></span>
</div>
`;
document.body.appendChild(controls);
this.uiElement = controls;
// Add styles dynamically if not already present
if (!document.getElementById('controls-styles')) {
const style = document.createElement('style');
style.id = 'controls-styles';
style.textContent = `
#controls {
position: fixed;
top: 20px;
left: 20px;
z-index: 10;
background: rgba(0,0,0,0.7);
padding: 15px;
border-radius: 8px;
color: white;
font-family: sans-serif;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 300px;
}
.control-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.control-group label {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.control-group input[type="range"] {
flex: 2;
}
.control-group span {
min-width: 50px;
text-align: right;
font-size: 12px;
color: #aaa;
}
.control-group input[type="number"],
.control-group input[type="text"] {
width: 70px;
padding: 3px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 4px;
color: white;
text-align: right;
}
.control-group input[type="color"] {
width: 40px;
height: 24px;
border: none;
background: none;
cursor: pointer;
}
#controls h3 {
margin-bottom: 5px;
}
`;
document.head.appendChild(style);
}
// Bind event listeners
this.bindControlEvents();
}
/**
* Binds event listeners to controls panel elements
* Connects UI inputs to animation settings
*/
bindControlEvents() {
const self = this;
// Helper function to create update functions
const createUpdateFunction = (
elementIds,
settingKey,
converter = Number,
) => {
const elements = elementIds.map((id) => document.getElementById(id));
elements.forEach((el) => {
if (!el) return;
const eventType = el.type === 'checkbox' ? 'change' : 'input';
el.addEventListener(eventType, () => {
const value =
el.type === 'checkbox' ? el.checked : converter(el.value);
self.updateSettings({ [settingKey]: value });
});
});
};
// Bind all controls
createUpdateFunction(['speed', 'speed-number'], 'speed');
createUpdateFunction(['frequency', 'frequency-number'], 'frequency');
createUpdateFunction(['height', 'height-number'], 'height');
createUpdateFunction(['blur', 'blur-number'], 'blur');
document.getElementById('edge-blur').addEventListener('change', (e) => {
self.updateSettings({ edgeBlur: e.target.checked });
// Show/hide edge padding control based on edge blur state
const edgePaddingGroup = document.getElementById('edge-padding-group');
if (edgePaddingGroup) {
edgePaddingGroup.style.display = e.target.checked ? 'flex' : 'none';
}
});
createUpdateFunction(
['edge-padding', 'edge-padding-number'],
'edgePadding',
);
createUpdateFunction(['pixels', 'pixels-number'], 'pixels');
createUpdateFunction(['grain', 'grain-number'], 'grain');
document
.getElementById('show-second-wave')
.addEventListener('change', (e) => {
self.updateSettings({ showSecondWave: e.target.checked });
});
document.getElementById('sync-waves').addEventListener('change', (e) => {
self.updateSettings({ syncWaves: e.target.checked });
});
createUpdateFunction(
['edge-inset-left', 'edge-inset-left-number'],
'edgeInsetLeft',
);
createUpdateFunction(
['edge-inset-right', 'edge-inset-right-number'],
'edgeInsetRight',
);
createUpdateFunction(
['edge-transition-left', 'edge-transition-left-number'],
'edgeTransitionLeft',
);
createUpdateFunction(
['edge-transition-right', 'edge-transition-right-number'],
'edgeTransitionRight',
);
createUpdateFunction(['color', 'color-text'], 'color', String);
const customColorCheckbox = document.getElementById(
'custom-second-wave-color',
);
const secondWaveColorGroup = document.getElementById(
'second-wave-color-group',
);
customColorCheckbox.addEventListener('change', (e) => {
secondWaveColorGroup.style.display = e.target.checked ? 'flex' : 'none';
self.updateSettings({ useCustomSecondWaveColor: e.target.checked });
});
createUpdateFunction(
['second-wave-color', 'second-wave-color-text'],
'secondWaveColor',
String,
);
// TODO: Transparent background control (temporarily disabled)
// const transparentCheckbox = document.getElementById("transparent");
// const backgroundColorGroup = document.getElementById("background-color-group");
// transparentCheckbox.addEventListener("change", (e) => { ... });
// TODO: Background color control (temporarily disabled)
// const backgroundColorInput = document.getElementById("background-color");
// const backgroundColorText = document.getElementById("background-color-text");
// backgroundColorInput.addEventListener("input", () => { ... });
// Initialize UI values with current settings
this.syncControlsUI();
}
/**
* Synchronizes controls panel values with current animation settings
* Updates all UI elements to reflect current state
*/
syncControlsUI() {
const setVal = (ids, val) => {
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = val;
});
};
const setDisplay = (id, display) => {
const el = document.getElementById(id);
if (el) el.style.display = display ? 'flex' : 'none';
};
setVal(['speed', 'speed-number'], this.settings.speed);
setVal(['frequency', 'frequency-number'], this.settings.frequency);
setVal(['height', 'height-number'], this.settings.height);
setVal(['blur', 'blur-number'], this.settings.blur);
setVal(['pixels', 'pixels-number'], this.settings.pixels);
setVal(['grain', 'grain-number'], this.settings.grain);
setVal(['color', 'color-text'], this.settings.color);
setVal(
['second-wave-color', 'second-wave-color-text'],
this.settings.secondWaveColor,
);
const speedValue = document.getElementById('speed-value');
if (speedValue) speedValue.textContent = this.settings.speed;
const frequencyValue = document.getElementById('frequency-value');
if (frequencyValue) frequencyValue.textContent = this.settings.frequency;
const heightValue = document.getElementById('height-value');
if (heightValue) heightValue.textContent = this.settings.height;
const blurValue = document.getElementById('blur-value');
if (blurValue) blurValue.textContent = this.settings.blur;
const pixelsValue = document.getElementById('pixels-value');
if (pixelsValue) pixelsValue.textContent = this.settings.pixels;
const grainValue = document.getElementById('grain-value');
if (grainValue) grainValue.textContent = this.settings.grain;
const colorValue = document.getElementById('color-value');
if (colorValue) colorValue.textContent = this.settings.color;
const secondWaveColorValue = document.getElementById(
'second-wave-color-value',
);
if (secondWaveColorValue)
secondWaveColorValue.textContent = this.settings.secondWaveColor;
// TODO: backgroundColorValue hidden with bgColor control
const showSecondWaveEl = document.getElementById('show-second-wave');
const syncWavesEl = document.getElementById('sync-waves');
const customColorEl = document.getElementById('custom-second-wave-color');
const edgeBlurEl = document.getElementById('edge-blur');
const edgeBlurValue = document.getElementById('edge-blur-value');
const edgePaddingEl = document.getElementById('edge-padding');
const edgePaddingNumberEl = document.getElementById('edge-padding-number');
const edgePaddingValue = document.getElementById('edge-padding-value');
const edgePaddingGroup = document.getElementById('edge-padding-group');
if (showSecondWaveEl)
showSecondWaveEl.checked = this.settings.showSecondWave;
if (syncWavesEl) syncWavesEl.checked = this.settings.syncWaves;
if (customColorEl) {
customColorEl.checked = this.settings.useCustomSecondWaveColor;
setDisplay(
'second-wave-color-group',
this.settings.useCustomSecondWaveColor,
);
}
if (edgeBlurEl) {
edgeBlurEl.checked = this.settings.edgeBlur;
if (edgePaddingGroup) {
edgePaddingGroup.style.display = this.settings.edgeBlur
? 'flex'
: 'none';
}
}
if (edgeBlurValue)
edgeBlurValue.textContent = this.settings.edgeBlur ? 'Enabled' : '';
if (edgePaddingEl) edgePaddingEl.value = this.settings.edgePadding;
if (edgePaddingNumberEl)
edgePaddingNumberEl.value = this.settings.edgePadding;
if (edgePaddingValue)
edgePaddingValue.textContent = this.settings.edgePadding.toFixed(2);
const edgeInsetLeftEl = document.getElementById('edge-inset-left');
const edgeInsetLeftNumberEl = document.getElementById(
'edge-inset-left-number',
);
const edgeInsetLeftValue = document.getElementById('edge-inset-left-value');
const edgeInsetRightEl = document.getElementById('edge-inset-right');
const edgeInsetRightNumberEl = document.getElementById(
'edge-inset-right-number',
);
const edgeInsetRightValue = document.getElementById(
'edge-inset-right-value',
);
if (edgeInsetLeftEl) edgeInsetLeftEl.value = this.settings.edgeInsetLeft;
if (edgeInsetLeftNumberEl)
edgeInsetLeftNumberEl.value = this.settings.edgeInsetLeft;
if (edgeInsetLeftValue)
edgeInsetLeftValue.textContent = this.settings.edgeInsetLeft;
if (edgeInsetRightEl) edgeInsetRightEl.value = this.settings.edgeInsetRight;
if (edgeInsetRightNumberEl)
edgeInsetRightNumberEl.value = this.settings.edgeInsetRight;
if (edgeInsetRightValue)
edgeInsetRightValue.textContent = this.settings.edgeInsetRight;
const edgeTransitionLeftEl = document.getElementById(
'edge-transition-left',
);
const edgeTransitionLeftNumberEl = document.getElementById(
'edge-transition-left-number',
);
const edgeTransitionLeftValue = document.getElementById(
'edge-transition-left-value',
);
const edgeTransitionRightEl = document.getElementById(
'edge-transition-right',
);
const edgeTransitionRightNumberEl = document.getElementById(
'edge-transition-right-number',
);
const edgeTransitionRightValue = document.getElementById(
'edge-transition-right-value',
);
if (edgeTransitionLeftEl)
edgeTransitionLeftEl.value = this.settings.edgeTransitionLeft;
if (edgeTransitionLeftNumberEl)
edgeTransitionLeftNumberEl.value = this.settings.edgeTransitionLeft;
if (edgeTransitionLeftValue)
edgeTransitionLeftValue.textContent = this.settings.edgeTransitionLeft;
if (edgeTransitionRightEl)
edgeTransitionRightEl.value = this.settings.edgeTransitionRight;
if (edgeTransitionRightNumberEl)
edgeTransitionRightNumberEl.value = this.settings.edgeTransitionRight;
if (edgeTransitionRightValue)
edgeTransitionRightValue.textContent = this.settings.edgeTransitionRight;
}
/**
* Removes the controls panel from the DOM
* Called when checkbox is unchecked
*/
removeControls() {
if (this.uiElement && this.uiElement.parentNode) {
this.uiElement.parentNode.removeChild(this.uiElement);
this.uiElement = null;
}
}
}
Then init effect:
import { Wavy } from "wavy";
const canvas = document.getElementById("*your_canvas_id*");
const waves = new Wavy(canvas);
You can also download the minified version.
Params
Available parameters for Wavy constructor:
// Default values
new Wavy(canvas, {
speed: 50, // Animation speed (0-100)
frequency: 30, // Wave amplitude/frequency (0-100)
height: 50, // Wave height/top position (0-100)
blur: 30, // Blur intensity (0-100)
edgeBlur: false, // Enable edge fade to transparent effect
edgePadding: 0.04, // Edge padding zone size (0.0-0.5)
pixels: 0, // Pixelation strength (0-100)
grain: 0, // Grain noise intensity (0-100)
edgeInsetLeft: 0, // Left edge inset position (0-100, 0 = border, 100 = center)
edgeInsetRight: 0, // Right edge inset position (0-100, 0 = border, 100 = center)
edgeTransitionLeft: 0, // Left edge blur transition (0-100, 0 = sharp, 100 = 0.2 transition)
edgeTransitionRight: 0, // Right edge blur transition (0-100, 0 = sharp, 100 = 0.2 transition)
color: "#ff0000", // Primary wave color (hex)
showSecondWave: true, // Show second wave layer
syncWaves: true, // Sync wave animation speeds
useCustomSecondWaveColor: false, // Use custom second wave color
secondWaveColor: "#00ff00", // Secondary wave color (hex)
showControls: false, // Show/hide controls panel (use it only for dev mode!)
});
You can control the effect using the returned instance:
waves.updateSettings({ speed: 70, color: "#00ff00" }); // Update settings
waves.destroy(); // Completely remove the effect
Playground
Settings
Speed
50
Frequency
30
Height
50
Blur
30
Pixels
0
Grain
0
Edge inset L
0
Edge inset R
0
Edge transition L
0
Edge transition R
0
Color
#ff0000
On this page