Getting started

Button

Card

Effects

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