Wave effect with JavaScript and Canvas: wave animation with requestAnimationFrame

- Andrés Cruz - ES En español

Wave effect with JavaScript and Canvas: wave animation with requestAnimationFrame

See live example

In this post, we will see how to create a wave effect using JavaScript. To do this, we will use the HTML5 Canvas element together with the Window.requestAnimationFrame() function, which we already explored in depth in our previous article: The secret of animations in JavaScript (requestAnimationFrame()). As a quick refresher: requestAnimationFrame() allows you to animate shapes drawn on a <canvas> element in sync with the browser's refresh cycle. In other words:

With requestAnimationFrame(), you get smooth transitions thanks to a native API optimized to work alongside Canvas. Unlike setInterval() or setTimeout(), this function integrates directly with the browser's rendering engine.

For more information about this function, check out the link above.

What is a wave effect in JavaScript and why use Canvas

A wave animation is an animation that simulates the behavior of a wave or oscillation. It can be used for a wide variety of purposes:

  • Real-time data visualizations
  • Animated backgrounds on landing pages
  • Futuristic or artistic visual effects
  • Mathematical and scientific representations

Although there are "wave" effects built purely with CSS (such as ripple buttons), the <canvas> element gives you full control over every pixel and frame. When working with Canvas:

  • You draw directly onto a virtual canvas.
  • You precisely control coordinates, shapes, strokes, and colors.
  • You can apply real mathematics—including trigonometry—to movement.

That is why Canvas is the ideal tool for implementing sine waves and complex animations in the browser.

Why requestAnimationFrame is key for smooth animations

One of the most common mistakes when getting started with JavaScript animations is turning to setInterval() or setTimeout(). I did it myself at first… until I started noticing stuttering, excessive CPU usage, and unnatural movement.

requestAnimationFrame() solves all these problems because:

  • It syncs with the monitor's refresh rate (usually 60 fps).
  • It automatically pauses when the tab is inactive, saving system resources.
  • It produces noticeably smoother and more natural transitions.

In wave animations, where every frame counts, the difference compared to setInterval() is huge and very noticeable.

Building the wave with JavaScript and Canvas

The JavaScript code is actually quite short and simple, though it might look a bit confusing at first glance due to the mathematical operations involved (additions, divisions, and trigonometric functions). I will explain it step by step as clearly as possible.

Before diving into the code, it is worth recalling the sine and cosine functions you likely learned in school or college. These functions describe curves that wave back and forth periodically, just like an ocean wave:

Graph of the cosine function

Image source: Wikipedia – Cosine.

Sine and cosine functions applied to web animations

How a sine wave behaves

The cosine or sine function is perfect for this experiment precisely because of its oscillatory behavior. If you don't remember how they work, you will find plenty of material online; the key takeaway here is that they generate values that rise and fall continuously and predictably.

Applied in JavaScript, we use them like this:

  • Math.cos(n) — applies the cosine function to the value n.
  • Math.sin(n) — applies the sine function to the value n.

The trigonometric functions Math.sin() and Math.cos() generate periodic curves that smoothly rise and fall between -1 and 1. Exactly what we need to simulate a wave.

The general formula for a sine wave is:

y = amplitude * Math.sin(frequency * x + phase);

Where each parameter serves a specific purpose:

  • amplitude — controls the wave's maximum height (how "tall" the crest is).
  • frequency — defines how many complete oscillations occur across the width.
  • phase — incrementing this value in each frame generates horizontal motion.

Below is the complete JavaScript animation code:

var c = document.getElementById('canv');
var $ = c.getContext('2d');
var w = c.width = window.innerWidth;
var h = c.height = window.innerHeight;

var draw = function(t) {
  $.lineWidth = 1;
  $.fillStyle = 'rgb(0, 0, 0)';
  $.fillRect(0, 0, w, h);
  for (var i = -60; i < 60; i += 1) {
    $.strokeStyle = 'rgb(255, 255, 255)';
    $.beginPath();
    $.moveTo(0, h / 2);
    for (var j = 0; j < w; j += 10) {
      $.lineTo(10 * Math.cos(i) +
        j + (0.008 * j * j),
        Math.floor(h / 2 + j / 2 *
          Math.cos(j / 50 - t / 50 - i / 118) +
          (i * 0.9) * Math.cos(j / 25 - (i + t) / 65)));
    };
    $.stroke();
  }
}

var t = 0;
window.addEventListener('resize', function() {
  c.width = w = window.innerWidth;
  c.height = h = window.innerHeight;
}, false);

var run = function() {
  window.requestAnimationFrame(run);
  t += 5;
  draw(t);
};
run();

As you might imagine, the HTML consists of a simple <canvas> tag with a defined id.

A few considerations regarding the code above

First, we initialize global variables to gain access to the <canvas> element, its 2D context, and the window dimensions:

var c = document.getElementById('canv');
var $ = c.getContext('2d');
var w = c.width = window.innerWidth;
var h = c.height = window.innerHeight;

Inside the draw() function

We define stroke styles and clear the canvas on every frame before drawing again. This step is critical: if you do not clear the canvas with fillRect() before redrawing, previous strokes will accumulate, resulting in visual clutter.

$.lineWidth = 1;
$.fillStyle = 'rgb(0, 0, 0)';
$.fillRect(0, 0, w, h);
$.strokeStyle = 'rgb(255, 255, 255)';

This outer for loop draws a set of parallel lines. Changing the lower bound (-60) and upper bound (60) lets us make the waves wider or narrower. Additionally, this for loop initializes the paths required to render each line (beginPath(), moveTo(), and stroke()):

for (var i = -60; i < 60; i += 1) {
  $.beginPath();
  $.moveTo(0, h / 2);
    /* Nested for loop */
  $.stroke();
}

In other words, without this outer for loop, our wave would look like a lone whip moving from side to side:

Whip wave without the outer for loop

Why a single wave looks unnatural

Here is something I learned through trial and error:
when using a single sine function, the animation turns out too perfect… and for that reason, unnatural.

By combining multiple cosine functions with slight offsets and varying amplitudes, the wave begins to look more organic and fluid. That was the turning point where my animation stopped looking like a math exercise and started feeling alive.

The following snippet shows the nested for loop that draws w points per iteration, where w is the screen width in pixels:

for (var j = 0; j < w; j += 10) {
  $.lineTo(10 * Math.cos(i) +
    j + (0.008 * j * j),
    Math.floor(h / 2 + j / 2 *
      Math.cos(j / 50 - t / 50 - i / 118) +
      (i * 0.9) * Math.cos(j / 25 - (i + t) / 65)));
};

As you can see, we use multiple calls to Math.cos() with different arguments to create a diverse, non-monotonous wave effect. The overlapping of these curves creates that sense of natural motion.

The divisions and multiplications by small numbers—such as / 50, / 118, or * 0.9—keep values within a reasonable range and prevent the wave from distorting out of control as i and j increase.

To make the animation evolve over time, every time requestAnimationFrame() calls run() recursively, t increases by 5 units. This variable t acts as the phase that shifts the wave horizontally.

Finally, we create the run() function, which starts the main animation loop as soon as the page loads:

var run = function() {
  window.requestAnimationFrame(run);
  t += 5;
  draw(t);
};

Drawing the wave step by step: complete breakdown of draw()

function draw(t) {
 ctx.lineWidth = 1;
 ctx.fillStyle = 'rgb(0, 0, 0)';
 ctx.fillRect(0, 0, w, h);
 for (var i = -60; i < 60; i++) {
   ctx.strokeStyle = 'rgb(255,255,255)';
   ctx.beginPath();
   ctx.moveTo(0, h / 2);
   for (var j = 0; j < w; j += 10) {
     ctx.lineTo(
       10 * Math.cos(i) + j + (0.008 * j * j),
       Math.floor(
         h / 2 +
         j / 2 * Math.cos(j / 50 - t / 50 - i / 118) +
         (i * 0.9) * Math.cos(j / 25 - (i + t) / 65)
       )
     );
   }
   ctx.stroke();
 }
}

In short, the draw() function handles three key steps in each frame:

  • The first for loop (variable i) creates multiple parallel lines with distinct offsets.
  • The second for loop (variable j) plots each line point by point across the canvas width.
  • Combining several Math.cos() calls with unique arguments breaks perfect symmetry and creates organic motion.

When I removed that outer for loop during testing, the wave looked rigid like a whip. Adding it back gave the effect instant depth and volume.

Superimposing waves for an organic look

This superposition technique yields three visual qualities that a single sine wave can never produce:

  • Visual variation: no two sections of the canvas look identical.
  • Sense of volume: overlapping curves create an illusion of depth.
  • Non-repetitive movement: the animation never feels mechanical or static.

This is precisely what simple, single sine wave examples fail to deliver.

Controlling wave amplitude, frequency, and movement

  • Avoiding extreme distortions
    • A common oversight is failing to constrain loop variable values. If you don't manage division and multiplication factors within the Math.cos() arguments, the wave can grow exponentially out of control.
    • Pro tip: keeping divisors larger and multipliers smaller maintains a smooth, predictable animation.
  • Tweakable parameters for dramatic effect
    • Small changes lead to major visual updates. It is worth experimenting with:
      • Incrementing the phase slower (reducing the += 5 step for t).
      • Slightly tweaking amplitude by multiplying i by a different decimal value.
      • Introducing minor offsets inside Math.cos() parameters to vary the pattern.
  • In complex canvas animations, less is often more: subtle adjustments produce the most elegant results.

Optimization and performance in Canvas animations

Common mistakes that degrade performance in a canvas wave animation:

  • Redrawing without clearing the canvas first (using fillRect() or clearRect()).
  • Executing excessive mathematical calculations per frame without caching intermediate results.
  • Failing to update canvas dimensions on window resize events.

I always set up a resize listener so the canvas stays scaled to 100% of the viewport:

window.addEventListener('resize', function () {
 c.width = w = window.innerWidth;
 c.height = h = window.innerHeight;
});

Practical tips for stable and efficient animations

  • Always prefer requestAnimationFrame() over setInterval().
  • Avoid unnecessary loops or deeply nested logic inside draw().
  • Keep mathematical constants balanced: tuned divisors and multipliers make all the difference.
  • Test across different screen sizes to verify that the canvas scales correctly.

Wave effect variations and possible enhancements

Once you master the fundamentals, the possibilities expand quickly. You can explore:

  • Adding dynamic colors using hsl(), mapping i or t values to hue.
  • Introducing pseudo-random noise with Math.random() for more expressive, chaotic visual behavior.
  • Creating mouse-reactive waves by tracking coordinates via mousemove events.
  • Layering multiple wave groups at varying speeds to build depth of field.

This is where Canvas truly outshines CSS: creativity is your only boundary.

Frequently asked questions about JavaScript wave animation

  • Is Canvas better than CSS for this effect?
    • Yes, especially when you need true parametric waves and point-by-point mathematical control. CSS works fine for simpler effects like ripple buttons, but it lacks the flexibility of Canvas.
  • Can I use Math.sin() or Math.cos() interchangeably?
    • Yes. The main difference lies in their starting phase offset: sine starts at 0, whereas cosine starts at 1. In practice, for visual animation effects, the results are virtually identical.
  • Does this animation consume a lot of system resources?
    • When properly optimized, no. requestAnimationFrame() automatically pauses execution when the browser tab is hidden, keeping resource usage minimal.
  • Does this wave animation work on mobile devices?
    • Yes, provided you scale the canvas resolution and adjust your loop iterations to fit the processing capabilities of mobile hardware.

Conclusion

Building a wave effect in JavaScript goes far beyond a simple visual trick. Combining HTML5 <canvas>, requestAnimationFrame(), and trigonometry gives you smooth, organic, fully customizable animations without relying on external libraries.

In my experience, moving beyond a single wave to overlapping multiple Math.cos() functions with distinct phase offsets is what truly elevated the final visual quality. From there, the possibilities are endless: dynamic colors, mouse reactivity, depth layers… the canvas wave animation serves as a incredibly versatile foundation.

See live example

Create an animated wave effect with HTML5 Canvas and JavaScript. This tutorial explains how to use Math.sin, Math.cos, and requestAnimationFrame to build a smooth, organic wave animation without external libraries.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.