GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Canvas Wave Animation Scenes: Gerstner Field

A canvas wave animation is not a looping photo — every grid cell has its wave height recomputed in real time as a gerstner field from summed sines, and these

Auto-plays · click a tile to jump to its section · all nine in one zip

Why this order

The first scene is the trunk. Six sines of different wavelength, direction, and amplitude are summed into one surface grid, and the remaining eight scenes are each a single derived layer painted on top of that grid. Read the grid height as water depth and you get 02; keep only the cells whose height crosses a threshold and you get 03; dot the surface gradient against a sun vector and you get 04. Scene 05 tracks the foam of 03 by grid cell instead of by coordinate, scene 06 reuses that same gradient as a refraction offset, and scenes 07 and 08 layer local rotation and a global envelope onto the grid itself to make a rip current and an incoming wave wall. Scene 09, finally, presses both amplitude and color by distance, sinking the far water into haze.

One trunk, eight derivatives

The hgt array of scene 01 — the per-cell computed height — and its fieldcalc function are reused almost verbatim in all nine scenes. What changes is what you read out of that height: color makes a water surface, a threshold makes foam, a gradient makes glitter. Learn the one field calculation, and every other scene in this set becomes a single new read.

Near water first, horizon last — the order the math stacks

Scenes 01 through 06 are all the same surface seen from one viewpoint; 07 and 08 drop localized events onto that surface (a vortex, a surge), and 09 pulls the camera back to take in the horizon. If you are picking a subset for your own project, follow this order — each calculation you learn unlocks the next scene naturally.

01Summed-sine surface — a Gerstner wave field

Six sines of differing wavelength, direction, and amplitude are summed over a surface grid — with the Gerstner forward curl at crests included — into an ocean surface computed from scratch, suited to resort, cruise, and marine landing heroes (measured: 60.795% cumulative area, 23 of 24 frames moving).

Summed-sine surface — a Gerstner wave field
Open in a new tab

01 · Summed-sine surface — a Gerstner wave field — Measured area 60.795% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataupscalerequestAnimationFrame

Height from sine, curl from cosine — computed together

function fieldcalc(t, wstr, wang) {
  var i = 0;
  for (var y = 0; y < ROWS; y++) {
    for (var x = 0; x < COLS; x++, i++) {
      var h = 0, d = 0;
      for (var w2 = 0; w2 < NW; w2++) {
        var WV = WAVES[w2];
        var da = wang * 0.5;
        var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
        var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
        var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
        var a = WV.amp * wstr;
        h += a * Math.sin(ph);           // height — sum of six sines
        d += Q * a * Math.cos(ph) * dx;  // Gerstner horizontal curl
      }
      hgt[i] = h;
      disp[i] = d;
    }
  }
}

Why the cosine term is what makes it a wave

Drop the cosine term and the water only bobs up and down like a flat indoor pool, but keep it and the crests lean forward into their direction of travel — the one visual cue that turns an oscillating sheet into a wave — while the pointer measures your movement vector to retarget wind direction and strength, and the arrow keys steer the same values without a mouse.

02Depth attenuation — shallow-water cross-section

Reading surface-grid height as water depth, each pixel blends water color by exponential attenuation — deep navy below, bright mint above — for pool, diving, and travel-booking backgrounds (measured: 54.177% cumulative area, intensity average 54.2, the highest of the nine).

Depth attenuation — shallow-water cross-section
Open in a new tab

02 · Depth attenuation — shallow-water cross-section — Measured area 54.177% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataputImageDataexp

Why water swallows light exponentially, not linearly

function render(attenK) {
  var o = out.data;
  var baseline = RH * 0.4;   // surface centerline (buffer px)
  var ampPx = RH * 0.34;     // surface amplitude (buffer px)
  for (var cx = 0; cx < COLS; cx++) {
    var surfaceY = baseline + surf[cx] * ampPx;
    if (surfaceY < 0) surfaceY = 0; else if (surfaceY > RH - 2) surfaceY = RH - 2;
    for (var sx = 0; sx < STEP; sx++) {
      var px = cx * STEP + sx;
      for (var y = 0; y < RH; y++) {
        var base = (y * RW + px) * 4;
        if (y < surfaceY) {
          o[base] = 0; o[base + 1] = 0; o[base + 2] = 0; o[base + 3] = 0;
          continue;
        }
        var d = y - surfaceY;
        var u = 1 - Math.exp(-attenK * d);   // 0(shallow)..1(deep)
        if (u < 0) u = 0; else if (u > 1) u = 1;
      }
    }
  }
}

What the exponential curve buys you

A linear mix would fade color at the same rate no matter the depth and read as a flat gradient fill, but exponential attenuation changes fast in the first few pixels below the surface and slows as it deepens — the same curve real water follows when it swallows light — so everything above the waterline stays transparent, letting a card background double as sky, while stronger wind grows attenK and pulls the color under faster.

03Crest foam — whitecaps on the peaks

Cells where summed height crosses a fraction of the amplitude are painted pale cream as breaking whitecaps, with stronger wind lowering that threshold so the foam multiplies, for surfing and marine-sports content backgrounds (measured: 60.08% cumulative area, 22.649% peak single-frame area, second only to scene 07).

Crest foam — whitecaps on the peaks
Open in a new tab

03 · Crest foam — whitecaps on the peaks — Measured area 60.08% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

thresholdImageDatawind

A threshold that falls as the wind rises

// whitecap — cream once h crosses cut. Edges use smoothstep, the peak gets a second, brighter tone from FOAM2
var f = smoothstep(cut - FOAM_EDGE * eff, cut + FOAM_EDGE * eff, h);
if (f > 0) {
  var top = smoothstep(cut + FOAM_EDGE * eff * 2.2, cut + eff * 0.42, h);
  var fr = RF[0] * (1 - top) + RF2[0] * top;
  var fg = RF[1] * (1 - top) + RF2[1] * top;
  var fb = RF[2] * (1 - top) + RF2[2] * top;
  r = r * (1 - f) + fr * f;
  g = g * (1 - f) + fg * f;
  b = b * (1 - f) + fb * f;
}
// threshold falls as wind rises — 0.82 (light wind) down to 0.55 (strong wind)
function thresholdFor(w) {
  return 0.82 - 0.27 * Math.max(0, Math.min(1, (w - 0.35) / (2.1 - 0.35)));
}

Why the edge is smoothstep and the peak gets a second tone

Skip smoothstep on the edge and the threshold line breaks into a staircase, because every pixel is either past a hard cutoff or not, while skipping the second, brighter tone (FOAM2) at the very peak leaves the white spread in one flat shade that never quite looks like it is breaking apart.

04Specular highlight — sun glitter

Dotting the surface-grid gradient against a fixed sun direction gives specular strength, squared and stamped as glints so sun glitter sparks alive as the surface sways, for sunrise and night-beach campaign backgrounds (measured: 41.906% cumulative area, intensity average 43.0).

Specular highlight — sun glitter
Open in a new tab

04 · Specular highlight — sun glitter — Measured area 41.906% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

dotpowImageData

Squaring the normal dot product to narrow the sparkle

var lx = Math.cos(sunAng) * (1 - sunZ), ly = Math.sin(sunAng) * (1 - sunZ), lz = sunZ + 0.3;
var llen = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= llen; ly /= llen; lz /= llen;
// central-difference gradient — approximates the surface normal (nx, ny, nz)
var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
var nx = -gx * 5.2, ny = -gy * 5.2, nz = 1;
var nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);
nx /= nlen; ny /= nlen; nz /= nlen;
var d = nx * lx + ny * ly + nz * lz;
if (d < 0) d = 0;
var spec = Math.pow(d, SHINY);   // dot(N,L)^shininess — a narrow, sharp glint

Why the raw dot product has to be raised to a power

Left as a plain dot product, the whole surface would wash out white wherever it faces the sun, but raising it to a power (SHINY) collapses that wash into isolated points of light — tighter as the exponent grows — while the pointer position becomes the sun's azimuth and height (sunZ), so you can drag the sun around inside the frame directly.

05Foam particles — grid-cell fragments

Foam shed from crests scatters as particles whose position and life are computed from the grid cell each was born in, with no coordinate re-randomizing, leaving the trace of waves charging and receding for summer-event and beach-festival mood backgrounds (measured: 60.21% cumulative area, intensity average 21.0).

Foam particles — grid-cell fragments
Open in a new tab

05 · Foam particles — grid-cell fragments — Measured area 60.21% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-cellparticleslifecycle

Advection by grid cell instead of random coordinates

// advect — pulls density from upstream (the previous position) into this cell
var sx = x - flowX, sy = y - flowY;
var ix = Math.round(sx), iy = Math.round(sy);
if (ix < 0) ix = 0; else if (ix >= COLS) ix = COLS - 1;
if (iy < 0) iy = 0; else if (iy >= ROWS) iy = ROWS - 1;
var carried = dens[iy * COLS + ix] * decay;
// spawn — only crest cells roll a deterministic random check (same cell, same frame -> same result)
if (hgt[idx] > cut) {
  var r = rngSpawn();
  if (r < spawnRate) carried = Math.min(1, carried + 0.55 + r * 0.4);
}
densNext[idx] = carried;

Why the foam is carried, not reborn, every frame

Re-rolling coordinates every frame makes foam vanish the instant the wind eases because nothing links one frame's dots to the next, while carrying density in a fixed grid cell and letting decay fade it lets foam linger and thin out the way it actually does when a wave pulls back. A canvas wave animation codepen usually fakes this layer with plain moving dots — here the foam lives inside the same field the waves do.

06Surface refraction — wobble caustics

Holding a backdrop in pixels and using the surface gradient as a source-offset pulls the original pixels askew — a refraction approximation whose caustics wobble whatever sits beneath — for aquarium, spa, and product water-look backgrounds (measured: 41.565% cumulative area, intensity average 22.9).

Surface refraction — wobble caustics
Open in a new tab

06 · Surface refraction — wobble caustics — Measured area 41.565% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDataoffsetgradient

Reading caustic strength from gradient divergence

var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
// divergence approximation — the gap between neighboring gradients becomes caustic strength
var gxr = x1 < COLS - 1 ? (hgt[row + Math.min(COLS - 1, x1 + 1)] - hgt[row + x1]) : gx;
var gyd = y1 < ROWS - 1 ? (hgt[Math.min(ROWS - 1, y1 + 1) * COLS + x] - hgt[y1 * COLS + x]) : gy;
var div = Math.abs((gxr - gx) + (gyd - gy)) * 90;
if (div > 1) div = 1;
var offx = gx * REFRACT, offy = gy * REFRACT;
var bx0 = x * STEP + STEP / 2, by0 = y * STEP + STEP / 2;
var c = tileColor(bx0 + offx, by0 + offy);   // floor color pulled by the offset

Why the offset multiplier has a ceiling

Raising the refraction multiplier (REFRACT) bends the floor pattern harder, but push it too far and the sampled coordinates jump enough to tear the tiling apart at the seams, while cells where gradient divergence runs high get a mint tint added on top — which is what reads as light gathering into a caustic.

07Rip current — a swirling offshore patch

Layering localized rotation, its strength falling off with radius, onto the wave field makes waves bunch and race offshore like a rip current, with the vortex core wandering over time, suited to marine-safety and educational visualizations (measured: 60.735% cumulative area, 25.676% peak single-frame area, the highest of the nine).

Rip current — a swirling offshore patch
Open in a new tab

07 · Rip current — a swirling offshore patch — Measured area 60.735% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotationfalloffImageData

Squaring the falloff to keep the rotation centered

function fieldcalc(t, wstr, wang, cx, cy, sw) {
  var i = 0;
  for (var y = 0; y < ROWS; y++) {
    for (var x = 0; x < COLS; x++, i++) {
      var dxv = x - cx, dyv = y - cy;
      var r = Math.sqrt(dxv * dxv + dyv * dyv);
      var ang = Math.atan2(dyv, dxv);
      var fall = r < VORTEX_R ? 1 - r / VORTEX_R : 0;
      fall *= fall;                    // squaring speeds the falloff — keeps the center sharp
      var twist = SWIRL * sw * fall * ang;
      // this twist is added into the wave phase, so only water near the vortex bends
    }
  }
}

Why squaring the falloff matters, and why the pointer is the center

Multiply by the falloff only once and the rotation smears out across the vortex's whole radius, reading as vague and mushy, but squaring it keeps only the few cells nearest the center turning visibly, and because the pointer coordinates feed directly into cx, cy, you can drag the vortex core anywhere on the screen.

08Sweep surge — an incoming wave wall

Locally inflating the field amplitude as a function of distance and time sends a single body of water surging toward the horizon, with the ebb dipping the surface as it withdraws, built for disaster- and documentary-style opening sequences (measured: 60.026% cumulative area, intensity average 37.9).

Sweep surge — an incoming wave wall
Open in a new tab

08 · Sweep surge — an incoming wave wall — Measured area 60.026% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

envelopeamplitudetiming

An asymmetric envelope that inflates crests more than it drops troughs

// envelope — a slow waveform traveling from below (near) to above (the horizon)
var om = SURGE_OM * (0.55 + wind * 0.75);
var yn = y / ROWS;
var sw = Math.sin(yn * Math.PI * 2 / SURGE_LAM - t * om + 0.7);
var crest = sw > 0 ? sw * sw : 0;                 // the incoming wall — positive half only
var dip = sw < 0 ? -sw * sw * 0.42 : 0;           // the ebb that dips after it passes
var e = 1 + crest * SURGE_GAIN * (0.55 + wind * 0.5) - dip;
env[y] = e;
// the envelope inflates amplitude and nudges it outward — the water bulges upward
hgt[i] = h * e + (e - 1) * 0.34;

Why the trough is damped instead of matched to the crest

Apply the same coefficient to crest and trough and the water only breathes evenly in place, but damping the trough to 0.42 of the crest's strength is what makes the surge feel like it is pushing in one direction, and raising wind speeds up the surge period (om) along with it.

09Horizon haze — the distance washed out

Aerial perspective mixes water color into sky color in proportion to each pixel's distance from the horizon while amplitude flattens with range, completing a seascape whose distance washes out, meant for travel, meditation, and mood-app backgrounds (measured: 34.095% cumulative area — with half the frame given to sky, a lower number here is expected, not a flaw).

Horizon haze — the distance washed out
Open in a new tab

09 · Horizon haze — the distance washed out — Measured area 34.095% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lerpaerialImageData

One distance value presses frequency, amplitude, and color together

function fieldcalc(t, wstr, wang, horizonRow) {
  var i = 0;
  for (var y = 0; y < ROWS; y++) {
    if (y < horizonRow) { i += COLS; continue; }   // above the horizon is sky — skip the math
    var dist = ROWS > horizonRow ? (y - horizonRow) / (ROWS - horizonRow) : 1;
    if (dist > 1) dist = 1; else if (dist < 0) dist = 0;
    var freqMul = 1 + (1 - dist) * 2.4;   // closer means a tighter apparent wavelength
    var ampMul = 0.15 + dist * 0.85;      // farther means amplitude gets pressed into haze
    for (var x = 0; x < COLS; x++, i++) {
      // the core math matches scene 01 — only freqMul and ampMul get multiplied in
    }
  }
}

Why the cumulative-area number is not a target to chase here

Forcing the wave area up would mean drawing ripples above the horizon into the sky, which is not a real seascape, so pressing amplitude and color together by the same distance value lets the far water settle on its own, and the pointer's vertical position sets the target horizon height (horizonRow).

How do summed sines become an ocean?

One sine alone draws a regular ripple pattern, not a sea. To read as an ocean, several sines of different wavelength, direction, and speed must be summed on the same grid so their crests and troughs miss each other irregularly and the surface stops looking periodic. Scene 01's WAVES array builds six of them: wavelength starts at 34 and shrinks by a factor of 0.55 each step, while amplitude falls off as 0.58/(i*0.62+1) with the index, so the big waves carry the base shape and the small ones lay ripples on top — long swells moving deep, short chop moving shallow, exactly as a real sea behaves. To imitate the physics a step further, the Gerstner curl multiplies a cosine term into each wave's direction vector (dirX) so water near a crest leans slightly into its direction of travel, approximating the sharp-crest, gentle-trough asymmetry of a real wave.

Why a sine and cosine pair instead of a sine alone

Height comes from Math.sin(ph) but horizontal displacement comes from Math.cos(ph). Sine and cosine sit 90 degrees out of phase: where the height peaks, cosine is near zero, and where the height is mid-slope, cosine is at its maximum. That offset is what pushes crests forward as they rise while troughs only widen sideways, and it is the difference between a stack of overlapping sine bands and a surface that genuinely reads as wave motion graphics.

Why shorter wavelengths must come with smaller amplitudes

Keep shrinking the wavelength while holding amplitude, and the short waves cover the screen in a dense stripe pattern — a buzzing noise, not water. Real seas also tie the two together: short chop is shallow, long swell is deep. Invert the ratio and the screen sizzles; stretch it too far and the ripples vanish into a bland sheet. The 0.55 wavelength step with its matching amplitude falloff is the balance point used here.

Are six waves enough, or do you need more?

More waves make a richer surface, but the inner loop runs once per wave per cell, so per-frame cost grows linearly with the count. Six hold the balance between shape and speed in scene 01, and in a haze-heavy scene like 09, extra waves barely survive the distance compression anyway. The wave count is also the first knob in the variants table below.

Where it breaks — four fixes that took 15.2% to 60.8%

Scene 01's first render measured only 15.2% cumulative area. The code ran without errors and the screen showed water, so it was the measurement that revealed most frames were nearly static. The cause was not one bug but four stacked on top of each other, and only after fixing the first did the next become visible.

First — the STEP blocks filled by a running counter

Upscaling the low-res grid into the real buffer, the first version addressed each block with a counter incremented in loop order, but the loop's order did not match the buffer's actual row and column order. The mismatch showed up as water splitting and shearing toward the bottom of the frame — even a few rows of offset is glaringly visible to the eye.

Second — Sass emitting rgb() in percentages

The next failure was color: Sass compiled mix() into rgb(52.9%, 78.1%, 69%), but the hex2rgb helper that turns a color into an array only parsed hex strings. On a failed match it threw no exception and quietly returned black (0,0,0), so the console stayed clean while the canvas died. Teaching the parser the percent format — multiply by 2.55 — brought the color back.

Third — multiplying 2π into a frequency that already had it

Spatial frequency was already defined as freq = 2π/λ, but the phase calculation multiplied the coefficient in again out of habit. The effective wavelength grew far longer than the grid domain, so within the frame the water showed up as one nearly flat slope. Multiplying a scale factor of 0.62 into the phase is what fits several crests and troughs into the grid.

Fourth — color ramp endpoints that never appeared

The largest gain came last. Summed height lands roughly between -0.75 and 0.75, but mapping that straight into a 0-to-1 color ramp coordinate means the values only ever occupy 0.15 to 0.85, so the ramp's two extreme colors — the brightest crest tint and the deepest trough tint — never appeared once. With the color bunched into the midtones, even moving waves changed too little luminance for the measurement script to register, and frame after frame read as "no change". Widening the normalization to h * 1.24 + 0.5 so the clamp reaches both ends of the ramp lifted the cumulative area to 60.795%. The transferable lesson: when an animation feels slow or still, the cause is often not the frame rate but a value range that never touches the ends of its color ramp.

The fix order and the evidence behind each step are documented inside this article's zip, and the password you type to open it is jy92b5ca.

Three variants and accessibility

On the same wave-field calculation, three values alone are enough to change the mood entirely.

Variant Value changed Result
Fewer waves NW from 6 to 3 The ripples thin out into a calm, lake-like surface
Softer crest threshold thresholdFor ceiling from 0.82 to 0.55 Foam spreads from dots into wide bands
Palette swap $ocean-mint toward lilac The scene shifts from midday sea to sunset sea

For a moving screen, accessibility comes in three parts by default. When prefers-reduced-motion: reduce is detected the loop stops and a single still frame drawn by the still() function keeps the wave structure visible without motion. The canvas carries a tabindex, so once focused, the arrow keys adjust wind strength and direction and the interaction works without a mouse. An aria-label on the canvas element tells screen-reader users what this ocean scene draws.

FAQ

Can I drop a canvas wave effect into a hero section as-is?

Yes — each scene is one canvas, one requestAnimationFrame loop, and a handful of pure functions, so pasting the file in and pointing one requestAnimationFrame call at it is the whole integration. prefers-reduced-motion is already honored in all nine scenes, but pausing the loop when the tab goes to the background is not built in — add a visibilitychange check yourself before dropping one of these into a public page.

How low can the grid resolution go before the canvas wave animation breaks?

All nine scenes compute on a low-res grid and upscale it with drawImage. A surface that moves in large masses like water tolerates a coarse grid upscaled in STEP blocks without visible offense, while a full-resolution grid multiplies the per-frame cost for no visible gain. The safe limit depends on screen size and wavelength, so test by switching the upscale factor between 2x and 4x and watching the water — not the numbers.

Is there a css wave animation background version too?

If you only need a static-feeling decorative backdrop, a css wave animation background built from layered gradients is lighter still — but it cannot respond to the pointer, and none of the depth, foam, or refraction logic above survives the translation. This set stays on canvas precisely because the wind vector is live, and the zip includes a React port of each scene alongside the vanilla files for dropping them into components.

Further reading

Enter the archive password

The password is inside this article. You will find it as you read.