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

Mesh Gradient Background: 9 Canvas Animations

A mesh gradient looks finished once a few colors are blurred together, until the middle turns muddy and dark areas split into bands — scenes 05 and 06 each

See All Nine Mesh Gradient Scenes Running on This Page

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

Mesh Gradient Animation: 9 Scenes Where Every Pixel Computes Its Color

If you came looking for a mesh gradient generator with sliders, this page is the other kind: nine scenes where JavaScript works out the color of every pixel, and the knobs are constants in the file. All nine stand on one frame. The canvas holds 160 by 100 cells (CALC_W and CALC_H), each frame writes the color of all 16,000 cells into an ImageData made with createImageData, sends it up with putImageData, and lets CSS stretch the result to a 16:10 box. The cell count stays low on purpose, since every extra cell adds the same share of work to every frame.

On top of that frame each scene changes one way of mixing color. Scene 01 shows that a bilinear blend of four corners is the smallest mesh gradient, 02 spreads it over 3×3 control points, and 03 drops the grid and places free color points. Scene 04 bends the coordinates the color is read from with noise, 05 and 06 are side-by-side comparisons that repair one mixing trap each, 07 reads brightness as height to add a sheen, 08 turns the hue angles, and 09 stacks 03's point blending with edge darkening and grain behind a headline.

Treat it as a small mesh gradient animation js reference. Time comes from requestAnimationFrame timestamps gathered in 16 ms steps (FIXED_DT) and handed to paint(t, d) in seconds, and the only random numbers come from a seeded seedRand, so one moment always draws one picture. There is no library, no WebGL and no outside address. For two neighbors that also compute color per pixel, compare HTML5 Canvas Animation: 9 Warp & Droplet Effects and Rainbow Gradient Background CSS: 9 Patterns; the first bends an image, the second layers sine waves into color bands.

Code for Each Color-Mixing Technique

Every snippet below is a run of consecutive lines cut from the demo source in their original order, and the complete files are in the zip. The helper pick(pal, pos, out) reads a color list as a loop: the integer part of pos chooses a color and the fraction blends toward the next one linearly. The color cycling in 01 and 02 comes from this one function; 08 never calls it and turns the hue angle directly with HUE_SPEED.

01Base Mesh Blending Four Corner Colors

CORNER_RATE (2 per second) moves each corner one step along a pink, lilac, sky and mint list, clockwise, and every pixel calls lerp twice across and once down to take a weighted average of the four colors. The middle is an equal share of all four, so it tends to look duller than the corners, and this bilinear frame is the skeleton behind every mesh gradient. Over 23 of 23 measured frames, scene 01 accumulated 60.9% repaint area at an average intensity of 15.9.

Base Mesh Blending Four Corner Colors
Open in a new tab

01 · Base Mesh Blending Four Corner Colors — Measured area 60.93% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CORNER_RATECORNER_PALCALC_WCALC_H
      var CORNER_RATE = 2;
      var CORNER_PAL = [PAL[0], PAL[1], PAL[2], PAL[3]];
      var corners = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]];
      function paint(t, d) {
          // top left, top right, bottom right, bottom left: each corner takes the next neighbouring entry of the colour list
          pick(CORNER_PAL, t * CORNER_RATE + 0, corners[0]);
          pick(CORNER_PAL, t * CORNER_RATE + 1, corners[1]);
          pick(CORNER_PAL, t * CORNER_RATE + 2, corners[3]);
          pick(CORNER_PAL, t * CORNER_RATE + 3, corners[2]);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var r = lerp(lerp(corners[0][0], corners[1][0], u), lerp(corners[2][0], corners[3][0], u), v);
                  var g = lerp(lerp(corners[0][1], corners[1][1], u), lerp(corners[2][1], corners[3][1], u), v);
                  var b = lerp(lerp(corners[0][2], corners[1][2], u), lerp(corners[2][2], corners[3][2], u), v);
                  put(d, x, y, r, g, b);
              }
          }

02Soft Mesh with Drifting 3x3 Control Points

Use this when a background shows cell-sized steps. Nine control points read the color list at NODE_RATE (1.2 per second) and sway near their anchors by SWAY (0.12), and the weights inside a cell pass through smooth, so the slope is zero at every cell border and the kink at the seams is reduced. Each pixel also shifts its reading position backward by the points' displacement so the picture bends with them; the measured result is 69.1% accumulated area at intensity 16.5 on 23/23 frames.

Soft Mesh with Drifting 3x3 Control Points
Open in a new tab

02 · Soft Mesh with Drifting 3x3 Control Points — Measured area 69.099% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWAYNODE_RATECALC_WCALC_H
      function meshAt(u, v, out) {
          var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
          var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
          var fx = smooth(gx - ix), fy = smooth(gy - iy);
          var a = nodeCol[iy * 3 + ix], b = nodeCol[iy * 3 + ix + 1];
          var c = nodeCol[(iy + 1) * 3 + ix], e = nodeCol[(iy + 1) * 3 + ix + 1];
          out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
          out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
          out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
      }
      function paint(t, d) {
          for (var n = 0; n < 9; n += 1) {
              pick(PAL, n * 0.7 + t * NODE_RATE, nodeCol[n]);
              nodeDx[n] = SWAY * Math.sin(6.2832 * (t * 1.2 + n * 0.37));
              nodeDy[n] = SWAY * Math.cos(6.2832 * (t * 1.05 + n * 0.51));
          }
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              var gy = v * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var gx = u * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
                  var k0 = iy * 3 + ix, k1 = k0 + 1, k2 = k0 + 3, k3 = k0 + 4;
                  // push the reading spot backward by as much as the control points moved, mixing the displacement with the same smooth weights
                  var du = lerp(lerp(nodeDx[k0], nodeDx[k1], fx), lerp(nodeDx[k2], nodeDx[k3], fx), fy);
                  var dv = lerp(lerp(nodeDy[k0], nodeDy[k1], fx), lerp(nodeDy[k2], nodeDy[k3], fx), fy);
                  meshAt(u - du, v - dv, meshTmp);
                  put(d, x, y, meshTmp[0], meshTmp[1], meshTmp[2]);
              }
          }
      }

03Bleeding Mesh Mixed by Inverse Distance

Six color points swim along looping orbits, and each pixel averages the six colors with the weight 1 / (d^POWER + EPS), where d is the distance to the point. You need no grid at all, only points, but every pixel measures six distances and calls Math.pow six times, so it costs the most work per cell. Scene 03 recorded 70.2% accumulated area at intensity 25.5 on 23/23 frames.

Bleeding Mesh Mixed by Inverse Distance
Open in a new tab

03 · Bleeding Mesh Mixed by Inverse Distance — Measured area 70.224% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

POWEREPSASPECTCALC_W
      var POWER = 2;
      var EPS = 0.002;
      var ASPECT = CALC_W / CALC_H;
      var SEED_FX = [0.31, 0.43, 0.37, 0.5, 0.27, 0.41];
      var SEED_FY = [0.39, 0.29, 0.47, 0.33, 0.45, 0.35];
      var SEED_PH = [0.0, 0.17, 0.33, 0.5, 0.67, 0.83];
      var dotX = [0, 0, 0, 0, 0, 0];
      var dotY = [0, 0, 0, 0, 0, 0];
      function paint(t, d) {
          for (var k = 0; k < 6; k += 1) {
              dotX[k] = (0.5 + 0.4 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
              dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.7));
          }
          var halfP = POWER / 2;
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 6; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + EPS);
                      wr += w * PAL[j][0];
                      wg += w * PAL[j][1];
                      wb += w * PAL[j][2];
                      ws += w;
                  }
                  put(d, x, y, wr / ws, wg / ws, wb / ws);
              }
          }
      }

04Mesh Flowing Through Noise-Warped Coordinates

Before any color is read, the coordinates u and v are pushed by WARP (0.3) using two layers of value noise, one for broad waves and one for fine ripples, and only then is a fixed 3×3 grid of colors read at the displaced spot. The colors never change, yet the edges curl like smoke, which is how a static gradient turns into a living backdrop. Scene 04 measured 65.6% accumulated area at intensity 18.1 on 23/23 frames.

Mesh Flowing Through Noise-Warped Coordinates
Open in a new tab

04 · Mesh Flowing Through Noise-Warped Coordinates — Measured area 65.601% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WARPLATTICECALC_WCALC_H
      function lat(ix, iy) {
          return lattice[(((iy % LATTICE) + LATTICE) % LATTICE) * LATTICE + (((ix % LATTICE) + LATTICE) % LATTICE)];
      }
      function vnoise(x, y) {
          var ix = Math.floor(x), iy = Math.floor(y);
          var fx = smooth(x - ix), fy = smooth(y - iy);
          return lerp(lerp(lat(ix, iy), lat(ix + 1, iy), fx), lerp(lat(ix, iy + 1), lat(ix + 1, iy + 1), fx), fy);
      }
      var warpTmp = [0, 0, 0];
      function paint(t, d) {
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  // broad waves plus fine ripples, two layers
                  var wu = (vnoise(u * 2.2 + t * 0.7, v * 2.2 - t * 0.4) - 0.5) * 2 + (vnoise(u * 5 - t * 0.9 + 7.7, v * 5 + t * 0.6) - 0.5) * 0.7;
                  var wv = (vnoise(u * 2.2 - t * 0.5 + 3.1, v * 2.2 + t * 0.8 + 5.3) - 0.5) * 2 + (vnoise(u * 5 + t * 0.8 + 1.9, v * 5 - t * 0.7 + 9.1) - 0.5) * 0.7;
                  meshRead(u + WARP * wu, v + WARP * wv, warpTmp);
                  put(d, x, y, warpTmp[0], warpTmp[1], warpTmp[2]);
              }
          }
      }

05A Plain Mix Beside a Gamma-Corrected Mix

Check this when the middle of two hues turns muddy. The same violet and orange are mixed at the same ratio s: the left half uses the stored values with lerp, while the right half raises them to GAMMA (2.2) to work in linear light, mixes, and converts back. Averaged over the full height at 0.9 seconds, the band center reads RGB 191,122,155 (brightness 146.4) on the left and 201,127,179 (155.1) on the right, and the scene scored 64.5% area at intensity 19.2 on 23/23 frames.

A Plain Mix Beside a Gamma-Corrected Mix
Open in a new tab

05 · A Plain Mix Beside a Gamma-Corrected Mix — Measured area 64.516% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAMMACOLOR_ACOLOR_BHALF
      var COLOR_A = [130, 90, 245];
      var COLOR_B = [255, 156, 60];
      var GAMMA = 2.2;
      var linA = [Math.pow(COLOR_A[0] / 255, GAMMA), Math.pow(COLOR_A[1] / 255, GAMMA), Math.pow(COLOR_A[2] / 255, GAMMA)];
      var linB = [Math.pow(COLOR_B[0] / 255, GAMMA), Math.pow(COLOR_B[1] / 255, GAMMA), Math.pow(COLOR_B[2] / 255, GAMMA)];
      var HALF = CALC_W / 2;
      function paint(t, d) {
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var right = x >= HALF;
                  var lx = (right ? x - HALF : x) / (HALF - 1);
                  // ratio s between the two colours; both halves use the same s
                  var s = smooth(lx + 0.22 * Math.sin(6.2832 * (v * 1.1 + t * 0.5)) + 0.1 * Math.sin(6.2832 * (v * 2.3 - t * 0.35)));
                  var r, g, b;
                  if (!right) {
                      r = lerp(COLOR_A[0], COLOR_B[0], s);
                      g = lerp(COLOR_A[1], COLOR_B[1], s);
                      b = lerp(COLOR_A[2], COLOR_B[2], s);
                  }
                  else {
                      r = 255 * Math.pow(lerp(linA[0], linB[0], s), 1 / GAMMA);
                      g = 255 * Math.pow(lerp(linA[1], linB[1], s), 1 / GAMMA);
                      b = 255 * Math.pow(lerp(linA[2], linB[2], s), 1 / GAMMA);
                  }
                  if (x === Math.floor(HALF) || x === Math.floor(HALF) - 1) {
                      r = 255;
                      g = 255;
                      b = 255;
                  }
                  put(d, x, y, r, g, b);
              }
          }
      }

06Grain Dither That Erases Banding

A dark radial gradient is rounded to steps of STEP (10) on purpose so bands show up. The left half fixes the rounding threshold at 0.5, while the right half shakes it by GRAIN using a seeded table of random numbers (seed 2026), which scatters the band edges. The threshold moves only inside one step, so no color lands more than one step from where it belongs; the scene measured 62.0% area at intensity 16.0 on 23/23 frames.

Grain Dither That Erases Banding
Open in a new tab

06 · Grain Dither That Erases Banding — Measured area 61.95% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEPGRAINNOISE_NDARKLIGHT
      var STEP = 10;
      var GRAIN = 1;
      var DARK = [30, 34, 66];
      var LIGHT = [112, 100, 170];
      var NOISE_N = CALC_W * CALC_H;
      var noiseTab = [];
      var rnd = seedRand(2026);
      for (var ni = 0; ni < NOISE_N; ni += 1) {
          noiseTab.push(rnd());
      }
      function paint(t, d) {
          var cx = 0.5 + 0.28 * Math.sin(6.2832 * t * 0.4);
          var cy = 0.5 + 0.2 * Math.cos(6.2832 * t * 0.31);
          var frame = Math.floor(t * 30);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var dx = (u - cx) * 1.6, dy = v - cy;
                  var k = 1 - clamp(Math.sqrt(dx * dx + dy * dy) * 1.05, 0, 1);
                  var right = x >= CALC_W / 2;
                  var n = noiseTab[(y * CALC_W + x + frame * 997) % NOISE_N];
                  // shake the rounding threshold per pixel within 0.5 ± GRAIN/2 of a step (the left half keeps it fixed at 0.5)
                  var th = right ? 0.5 + (n - 0.5) * GRAIN : 0.5;
                  var ch = [0, 0, 0];
                  for (var c = 0; c < 3; c += 1) {
                      var val = lerp(DARK[c], LIGHT[c], k);
                      ch[c] = Math.floor(val / STEP + th) * STEP;
                  }
                  if (x === Math.floor(CALC_W / 2) || x === Math.floor(CALC_W / 2) - 1) {
                      ch = [236, 232, 255];
                  }
                  put(d, x, y, ch[0], ch[1], ch[2]);
              }
          }
      }

07Mesh Read as Height with a Travelling Sheen

A mesh of four points weighted by inverse square fills a color buffer, and its brightness, squared, is read as height scaled by HEIGHT (0.9). Neighbor differences give the slope and a normal, and a light that circles once every 2.86 seconds adds diffuse shade plus a white highlight (SHINE 24), so you can see on one canvas which value makes a gloss flash across a button or card surface. Scene 07 measured 71.2% accumulated area at intensity 28.9 on 23/23 frames.

Mesh Read as Height with a Travelling Sheen
Open in a new tab

07 · Mesh Read as Height with a Travelling Sheen — Measured area 71.171% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HEIGHTSLOPE_GAINSHINESHEEN
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 4; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (dx * dx + dy * dy + 0.004);
                      wr += w * TONE[j][0];
                      wg += w * TONE[j][1];
                      wb += w * TONE[j][2];
                      ws += w;
                  }
                  var i = y * CALC_W + x;
                  colR[i] = wr / ws;
                  colG[i] = wg / ws;
                  colB[i] = wb / ws;
                  // take the brightness (0 to 1) and square it once more to use it as height
                  var lum = (0.299 * colR[i] + 0.587 * colG[i] + 0.114 * colB[i]) / 255;
                  hgt[i] = lum * lum * HEIGHT * CALC_H;
              }
          }
          var ang = 6.2832 * t * 0.35;
          var lx = Math.cos(ang) * 0.7, ly = Math.sin(ang) * 0.7, lz = 0.6;
          var ln = Math.sqrt(lx * lx + ly * ly + lz * lz);
          lx /= ln;
          ly /= ln;
          lz /= ln;
          // measure the highlight with the half vector (light plus view) instead of a reflection vector
          var hx = lx, hy = ly, hz = lz + 1;
          var hn = Math.sqrt(hx * hx + hy * hy + hz * hz);
          hx /= hn;
          hy /= hn;
          hz /= hn;
          for (var y2 = 0; y2 < CALC_H; y2 += 1) {
              var ya = Math.max(0, y2 - 1), yb = Math.min(CALC_H - 1, y2 + 1);
              for (var x2 = 0; x2 < CALC_W; x2 += 1) {
                  var xa = Math.max(0, x2 - 1), xb = Math.min(CALC_W - 1, x2 + 1);
                  var gx = (hgt[y2 * CALC_W + xa] - hgt[y2 * CALC_W + xb]) * SLOPE_GAIN * 10;
                  var gy = (hgt[ya * CALC_W + x2] - hgt[yb * CALC_W + x2]) * SLOPE_GAIN * 10;
                  var nn = Math.sqrt(gx * gx + gy * gy + 1);
                  var nx = gx / nn, ny = gy / nn, nz = 1 / nn;
                  var diff = clamp(nx * lx + ny * ly + nz * lz, 0, 1);
                  var spec = Math.pow(clamp(nx * hx + ny * hy + nz * hz, 0, 1), SHINE) * SHEEN;
                  var j2 = y2 * CALC_W + x2;
                  var shade = 0.72 + 0.4 * diff;
                  put(d, x2, y2, clamp(colR[j2] * shade + 255 * spec, 0, 255), clamp(colG[j2] * shade + 255 * spec, 0, 255), clamp(colB[j2] * shade + 255 * spec, 0, 255));
              }
          }

08Mesh Whose Hue Angles Turn to Shift the Mood

The hue angles of the nine control points turn by HUE_SPEED, between 66 and 174 degrees per second and some of them backward, while brightness rises and falls between 0.74 and 0.84 on an 8 second cycle (DAY_PERIOD). The source holds no names for times of day; one brightness curve is all that stands in for the change of mood. Scene 08 measured 62.6% accumulated area at intensity 15.6 on 23/23 frames.

Mesh Whose Hue Angles Turn to Shift the Mood
Open in a new tab

08 · Mesh Whose Hue Angles Turn to Shift the Mood — Measured area 62.622% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SATURATIONDAY_PERIODBASE_HUEHUE_SPEED
      function paint(t, d) {
          var phase = 0.5 - 0.5 * Math.cos(6.2832 * t / DAY_PERIOD);
          var light = 0.84 - 0.1 * phase;
          for (var n = 0; n < 9; n += 1) {
              hslToRgb(BASE_HUE[n] + HUE_SPEED[n] * t, SATURATION, light, hueCol[n]);
          }
          for (var y = 0; y < CALC_H; y += 1) {
              var gy = (y / (CALC_H - 1)) * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
              for (var x = 0; x < CALC_W; x += 1) {
                  var gx = (x / (CALC_W - 1)) * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
                  var a = hueCol[iy * 3 + ix], b = hueCol[iy * 3 + ix + 1], c = hueCol[(iy + 1) * 3 + ix], e = hueCol[(iy + 1) * 3 + ix + 1];
                  put(d, x, y, lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy), lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy), lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy));
              }
          }
      }

09Mesh Hero Backdrop Behind a Headline

Five color points in an inverse-distance mesh get an edge vignette (VIGNETTE 0.22) and seeded grain (seed 99, GRAIN_AMOUNT 5), and the headline sits above the canvas as separate page text. Sampling the area behind the title every 0.1 seconds for 60 seconds gave a worst-case contrast of 7.55:1 against the text color (#17141a), and since every color is a light pastel it stays above 4.5:1. Scene 09 measured 67.2% accumulated area at intensity 23.1 on 23/23 frames.

Mesh Hero Backdrop Behind a Headline
Open in a new tab

09 · Mesh Hero Backdrop Behind a Headline — Measured area 67.238% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

VIGNETTEGRAIN_AMOUNTPOWERNOISE_N
      function paint(t, d) {
          for (var k = 0; k < 5; k += 1) {
              dotX[k] = (0.5 + 0.42 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
              dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.6));
          }
          var halfP = POWER / 2;
          var frame = Math.floor(t * 30);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 5; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + 0.003);
                      wr += w * HERO_PAL[j][0];
                      wg += w * HERO_PAL[j][1];
                      wb += w * HERO_PAL[j][2];
                      ws += w;
                  }
                  // edge darkness: the farther from the centre, the more the multiplier falls from 1 toward 1 - VIGNETTE
                  var ex = (x / (CALC_W - 1) - 0.5) * 2, ey = (y / (CALC_H - 1) - 0.5) * 2;
                  var vig = 1 - VIGNETTE * smooth((Math.sqrt(ex * ex * 0.6 + ey * ey) - 0.55) / 0.9);
                  var grain = (noiseTab[(y * CALC_W + x + frame * 991) % NOISE_N] - 0.5) * GRAIN_AMOUNT;
                  put(d, x, y, clamp((wr / ws) * vig + grain, 0, 255), clamp((wg / ws) * vig + grain, 0, 255), clamp((wb / ws) * vig + grain, 0, 255));
              }
          }
      }

Where the Values Change the Scene — Control Points, Bleed Exponent, Grain Strength

Three values shape the impression: how far the control points sway (SWAY in 02), how widely each color point bleeds (POWER in 03) and how strong the grain that scatters the bands is (GRAIN in 06). I changed only those constants in Node, called the same paint, and compared two pictures of one moment. Raising SWAY to 0.24 moves pixels by 5.9 to 6.6 per channel on average and 0.30 moves them by 8.9 to 9.8, while POWER 1 drops the color spread to between 16 and 19 and POWER 4 lifts it to between 34 and 38. That spread is the standard deviation of the 0 to 255 values.

The speed constants (CORNER_RATE 2, NODE_RATE 1.2 and HUE_SPEED 66 to 174 degrees) are set fast on purpose, because the test renderer sees only one frame about every 130 ms. If you want a calm backdrop, lower those first.

Measured values for all nine scenes sit in the table. Scenes 01, 06 and 08 average 15.9, 16.0 and 15.6, closest to the pass line of 15, since their colors drift between neighbors and frame-to-frame differences stay small. All nine scenes showed motion in every one of the 23 frames.

Scene Accumulated area Average intensity Moved frames
01 Four-corner bilinear 60.9% 15.9 23/23
02 3×3 control points 69.1% 16.5 23/23
03 Inverse-distance bleed 70.2% 25.5 23/23
04 Noise-warped flow 65.6% 18.1 23/23
05 Gamma-mix comparison 64.5% 19.2 23/23
06 Grain dither 62.0% 16.0 23/23
07 Height and sheen 71.2% 28.9 23/23
08 Hue-angle cycling 62.6% 15.6 23/23
09 Headline hero 67.2% 23.1 23/23

Where It Breaks — Higher Resolution, Muddy Middles, Bands in Dark Areas

When the Middle Turns Muddy

The center of 01 is an average of four colors, so it is duller than the corners. Scene 05 puts a number on it: the center of the same two-color band has brightness 146.4 when mixed as stored values and 155.1 when mixed in linear light. The gap is modest (about 9 points of brightness at the band center), so do not expect a dramatic change, and the violet and orange there are saturated on purpose to make the muddiness visible, unlike the pastel palette of the other scenes. In 08, colors turning in opposite directions pass near complementary hues, and because blending happens in RGB space, the mix of two opposed hues drifts toward gray and loses saturation.

When Bands Appear in Dark Areas

The STEP of 10 in 06 is not a real display step (one in 255); it is rounded coarsely so the bands can be seen. Raising GRAIN from 1 to 1.5 makes the 8×8 box-average error grow from 0.32 to 0.46, because the threshold shakes only inside one step and a larger grain leaves that range.

When the Reading Spot Leaves the Edge

If you enlarge SWAY in 02 or WARP in 04, the reading position leaves the 0 to 1 range and clamp stretches the edge color outward. In 03, the color right on top of a point stays almost exactly that point's color, and it gets sharper as POWER grows. Scene 07 reads only brightness as height, so two different colors of equal brightness look flat.

When the Measurement Misses the Motion

In the headless render the real gap between frames was about 130 milliseconds. My first color cycle of 0.6 per second scored 58% accumulated area yet showed motion in 0 of 23 frames. Wrapping the cycle in smooth also stalled it at the end of each color cell, so pick became a linear blend and the speed went up.

When a Higher Resolution Multiplies the Work

Setting CALC_W and CALC_H to 320 by 200 gives four times as many cells, and 03 grows the most because it calls Math.pow six times per pixel. To try different resolutions by editing the constants yourself, open the zip below with the password w432zjum and change them in the file.

Three Variants and Accessibility

Name Changed value Feel
Widen the control point sway 02 SWAY 0.12 → 0.30 Widening the sway from 0.12 to 0.30 shifts pixel values by an average of 8.9 to 9.8 per channel out of 255 (up to 56 to 75 at the most) against the same moment, so color borders heave more and the bend near cell joints becomes visible.
Change the bleed exponent 03 POWER 2 → 4 Raising the exponent from 2 to 4 lifts the color spread over the canvas from about 28 to about 35 so each color point tightens into a clearer disc, while dropping it to 1 shrinks the spread to about 17 and blurs the canvas into one tone.
Adjust the grain strength 06 GRAIN 1 → 0 Dropping the grain strength from 1 to 0 stretches the average horizontal run of one color in the right half from 1.6 px to 5.6 px, close to the 6.6 px of the grain-free left half, so the bands come back.

With prefers-reduced-motion switched on, none of the nine scenes starts its requestAnimationFrame loop; each draws one fixed moment right away (REDUCE_MS, between 700 and 1200 milliseconds). The button then becomes a next-scene control that moves time forward by REDUCE_STEP_MS (900 milliseconds) per press and redraws at once, and the status line, an aria-live="polite" region, announces that it is stepping one scene at a time. With motion on, pressing the button or the canvas, or pressing Enter or Space on the canvas, toggles pause and play, and the status line announces the running and paused states. The canvas has tabindex 0 and the pause control is a button element, so the keyboard alone is enough to stop and restart a scene.

Can a Mesh Gradient Be Made with CSS Alone?

You can imitate one. The usual trick stacks a few radial gradients and moves their positions, which is enough for a mesh gradient animation css search that only needs a soft drifting glow. The calculations on this page weight each cell, bend the coordinates the color is read from and correct brightness, and that is one layer more than the stacked gradients can reach. For backgrounds where CSS is enough, see 9 CSS Background Animation — One Line, Copy-Paste, and for layers that build depth, see 9 CSS 3D Background Animation Depth Layers.

The drawing calls used here are documented in the Canvas API reference and the putImageData reference.

Frequently Asked Questions Before Using a Mesh Gradient

What Do I Change First to Use This as My Landing Page Backdrop?

Open scene 09 and edit the five color points of HERO_PAL and VIGNETTE. If a dark color drifts under the title, contrast can fall below 4.5:1, so run it for about a minute after any change and check that the text stays readable. A color list edited this way is all a mesh gradient maker needs here.

Do I Need an External Library or Image?

No. All nine scenes run on canvas 2d and one script in one file, and no line calls an outside address, so moving a single file keeps the scene playing exactly as it is.

What Should I Fix When It Stutters or the Middle Looks Muddy?

If it stutters, lower CALC_W and CALC_H to cut the cell count, and for a costly scene like 03 cut the number of points first. If the middle looks muddy, mix in linear light as scene 05 does, or use fewer colors that turn in opposite directions.

Enter the archive password

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