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

9 Perlin Noise Animations, No Libraries, Pure Math

Perlin noise animation smooths raw randomness into values that connect with their neighbours, and out of that one function come mountains, clouds, seas, and

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

Why this order

The order is not flashiest-first — it is the order noise stacks into land. A one-dimensional slice of noise read as height comes first and becomes a ridge line, then a sea profile (01, 04); the same field unfolded into two dimensions becomes maps (02, 08); hung in the sky it becomes drifting phenomena (03, 05, 09); and sharp transforms plus carved space bend the land itself at the end (06, 07). Most terrain generation algorithms you will meet elsewhere stack these same moves, which is why reading them in this order hands you the whole recipe. Neighbours on this site: the seeded CSS background patterns redraw their grain from one number, the canvas scene effects bend pixels through lenses and black holes, and the canvas fluid simulation advects grids instead of sampling fields.

01Elevation profile — fbm ridge silhouette

What you're seeing

Four octaves of 1D value noise stack into an fbm that draws a single ridge silhouette, and each octave's phase drifts at its own speed, so the ridge keeps re-carving itself. It belongs behind outdoor and hiking landing heroes.

Elevation profile — fbm ridge silhouette
Open in a new tab

01 · Elevation profile — fbm ridge silhouette — Measured area 31.644% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fbmlineTorequestAnimationFrame

Where to look in the code

The measurement recorded a cumulative area of 31.64%, an average intensity of 69.7, and movement in 23 of 23 frames.

var OCT = 4;   // four octaves

// fbm — stacks layers at double frequency and half amplitude. Each octave
// drifts on its own phase, pushed at (i + 1) times the flow, so high-frequency
// octaves slide faster and the ridge re-carves region by region
function fbm1(x, sec, flow) {
  var sum = 0, amp = 0.5, norm = 0, f = 1;
  for (var i = 0; i < OCT; i++) {
    var phase = sec * flow * (i + 1);
    sum += vnoise1(x * f + phase) * amp;
    norm += amp;
    amp *= 0.5;
    f *= 2;
  }
  return sum / norm;
}

02Contour map — hypsometric bands on canvas

What you're seeing

A 2D fbm heightfield is painted pixel by pixel through createImageData into five hypsometric bands, lifted with putImageData, and then thin threshold lines from the same field overlay it as contour rings. Travel, real-estate, and weather dashboard map cards take it.

Contour map — hypsometric bands on canvas
Open in a new tab

02 · Contour map — hypsometric bands on canvas — Measured area 42.324% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataputImageDatafbm

Where to look in the code

The measurement recorded a cumulative area of 42.32%, an average intensity of 43.9, and movement in 23 of 23 frames.

var MW = 160, MH = 100;                 // heightfield buffer — fixed no matter how large the screen grows
var buf = sctx.createImageData(MW, MH); // built once — the same array overwritten every frame
var px = buf.data;
var heights = new Float32Array(MW * MH); // kept for one frame so the line pass can re-read them

var SCALE = 0.052;          // grid spacing — smaller spacing, fatter mountains and valleys
var FLOW = 0.5;             // time-axis speed — how fast the ranges swell
var OCT = 5;                // fbm octave count — more octaves, denser rings
var LINE_LEVELS = 11;       // contour threshold steps (separate from the 5 colour bands)

03Cloud field — drifting fbm cloud layer

What you're seeing

Board 03 runs the set's only true gradient noise — direction vectors parked at each grid corner and dotted with the offset to the sample point — with time as the third axis rewriting a createImageData buffer every frame, and only values above a threshold stay opaque, so clumps drift and dissolve slowly. Weather apps and travel sites carry it as background dressing.

Cloud field — drifting fbm cloud layer
Open in a new tab

03 · Cloud field — drifting fbm cloud layer — Measured area 35.34% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataputImageDatarequestAnimationFrame

Where to look in the code

The measurement recorded a cumulative area of 35.34%, a peak frame area of 9.85%, and movement in 23 of 23 frames.

function lp(a, b, t) { return a + (b - a) * t; }
function gdot(h, x, y, z) { var i = (h & 255) * 3; return GRAD[i] * x + GRAD[i + 1] * y + GRAD[i + 2] * z; }

function noise3(x, y, z) {
  var xf0 = Math.floor(x), yf0 = Math.floor(y), zf0 = Math.floor(z);
  var xf = x - xf0, yf = y - yf0, zf = z - zf0;
  var X = xf0 & 255, Y = yf0 & 255, Z = zf0 & 255;
  var u = fade(xf), v = fade(yf), w = fade(zf);
  var A = PERM[X] + Y, AA = PERM[A] + Z, AB = PERM[A + 1] + Z;
  var B = PERM[X + 1] + Y, BA = PERM[B] + Z, BB = PERM[B + 1] + Z;
  return lp(
    lp(lp(gdot(PERM[AA], xf, yf, zf),             gdot(PERM[BA], xf - 1, yf, zf), u),
       lp(gdot(PERM[AB], xf, yf - 1, zf),         gdot(PERM[BB], xf - 1, yf - 1, zf), u), v),
    lp(lp(gdot(PERM[AA + 1], xf, yf, zf - 1),     gdot(PERM[BA + 1], xf - 1, yf, zf - 1), u),
       lp(gdot(PERM[AB + 1], xf, yf - 1, zf - 1), gdot(PERM[BB + 1], xf - 1, yf - 1, zf - 1), u), v),
    w);
}

04Sea swell — noise wave-height profile

What you're seeing

fbm sampled at each horizontal position becomes wave height, drawn as three layered curves joined with quadraticCurveTo, and flowing the noise over time makes the swell rise and fall with no fixed period. Ocean, surf, and resort landing backgrounds carry it.

Sea swell — noise wave-height profile
Open in a new tab

04 · Sea swell — noise wave-height profile — Measured area 29.956% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fbmquadraticCurveTorequestAnimationFrame

Where to look in the code

The measurement recorded a cumulative area of 29.96%, a peak frame area of 12.54%, and movement in 23 of 23 frames.

// one layer's wave height — the fbm value (which crowds toward the middle)
// gets a single contrast stretch, then swings around its base line
function swellY(x, sec, cfg) {
  var n = fbm1(x * cfg.scale + cfg.offset, sec, cfg.flow, cfg.oct);
  n = n * 2 - 0.5;
  return cfg.base - n * cfg.amp;
}

// sample points every 6px, join them with quadraticCurveTo using segment
// midpoints as anchors, fill to the canvas floor, stroke foam on the near layer
function drawSwell(sec, cfg, topColor, bottomColor, foam) {
  var step = 6;
  var pts = [];
  for (var x = 0; x <= W; x += step) pts.push([x, swellY(x, sec, cfg)]);

05Aurora curtain — vertical noise light veil

What you're seeing

Six tall fbm bands layer under globalCompositeOperation 'lighter', so crossings brighten by addition, and each band's noise phase runs at its own speed — the curtain sways and crosses like real aurora. Night scenes, events, and winter campaigns take it as the hero.

Aurora curtain — vertical noise light veil
Open in a new tab

05 · Aurora curtain — vertical noise light veil — Measured area 42.083% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataglobalCompositeOperationfbm

Where to look in the code

The measurement recorded a cumulative area of 42.08% against a peak frame area of just 4.05%, and movement in 23 of 23 frames — the whole curtain is perpetually re-woven.

function paintBand(b, z, cLow, cHigh) {
  var P = BP[b];
  var sig = P.sig, inv2 = 1 / (2 * sig * sig);
  var cut = 3.5 * sig;
  cpx.fill(0);
  var sway = new Float32Array(NH);      // lateral drift of the centreline — sampled once per row
  for (var yy = 0; yy < NH; yy++) {
    var sv = fbm(P.ph * 0.13, yy * 0.02, z * P.spd, 3);
    sway[yy] = (sv - 0.5) * 2 * P.amp;
  }
function paint(sec) {
  var z = sec * FLOW;                 // third axis = time
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = 'high';
  paintBack(z);
  ctx.globalCompositeOperation = 'source-over';
  ctx.drawImage(bg, 0, 0, NW, NH, 0, 0, W, H);
  // stacking — 'lighter' adds src + dst, so crossing curtains brighten on their own
  ctx.globalCompositeOperation = 'lighter';
  for (var b = 0; b < NB; b++) {
    paintBand(b, z, b % 2 ? CB1 : CA1, b % 2 ? CB2 : CA2);
    ctx.drawImage(curt, 0, 0, NW, NH, 0, 0, W, H);
  }
  ctx.globalCompositeOperation = 'source-over';
}

06Dune ridge — ridged-noise sand hills

What you're seeing

Taking the absolute value of fbm and inverting it — ridged noise — stands the crestlines sharp instead of rounded, and painting a front and back layer at different globalAlpha pushes the dunes into receding depth. Travel, desert-tour, and minimal brand backgrounds carry it.

Dune ridge — ridged-noise sand hills
Open in a new tab

06 · Dune ridge — ridged-noise sand hills — Measured area 30.374% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fbmlineToglobalAlpha

Where to look in the code

The measurement recorded a cumulative area of 30.37%, an average intensity of 52.6, and movement in 23 of 23 frames.

// ridged fbm — stretch vnoise1 (0..1) to -1..1, invert the absolute value
// (1 - |n|) to stand the crest sharp. Each octave drifts on its own phase,
// so the crest re-carves over time (same pattern as board 01)
function ridgedFbm1(x, sec, flow) {
  var sum = 0, amp = 0.55, norm = 0, f = 1;
  for (var i = 0; i < OCT; i++) {
    var phase = sec * flow * (i + 1);
    var n = vnoise1(x * f + phase);
    var s = n * 2 - 1;              // -1..1
    var r = 1 - Math.abs(s);        // ridged — 1 at the sign-change boundary, 0 in the troughs
    sum += r * amp;
    norm += amp;
    amp *= 0.5;
    f *= 2;
  }
  return sum / norm; // 0..1
}

07Cave cross-section — a cavity carved by noise

What you're seeing

Two fbm curves draw a ceiling and a floor, clip keeps only the cavity between them, and stalactite and stalagmite spots land where further noise readings peak. Exploration, game, and dark-fantasy landings take it.

Cave cross-section — a cavity carved by noise
Open in a new tab

07 · Cave cross-section — a cavity carved by noise — Measured area 31.048% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fbmquadraticCurveToclip

Where to look in the code

The measurement recorded an average intensity of 120.2 — the second-deepest change of the nine boards, behind the 179.8 of the 09 night sky — with a cumulative area of 31.05% and movement in 23 of 23 frames.

function paint(sec) {
  ctx.clearRect(0, 0, W, H);
  // 1) rock body — lay a dark stone gradient across the whole screen
  var rock = ctx.createLinearGradient(0, 0, 0, H);
  rock.addColorStop(0, C_ROCK_TOP);
  rock.addColorStop(1, C_ROCK_BOTTOM);
  ctx.fillStyle = rock;
  ctx.fillRect(0, 0, W, H);

  // 2) cavity — clip keeps only the space between the ceiling and floor curves
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(0, ceilY(0, sec));
  for (var x = 2; x <= W; x += 2) ctx.lineTo(x, ceilY(x, sec));
  for (var x2 = W; x2 >= 0; x2 -= 2) ctx.lineTo(x2, floorY(x2, sec));
  ctx.closePath();
  ctx.clip();
  // (cavity gradient, under-glow, and stalactites drawn while clipped, then restore)

08River erosion — steepest-descent flow line

What you're seeing

Each spring repeatedly steps to the lowest of its eight neighbouring cells on a 2D fbm heightfield, and lineTo traces the path that comes out — a river that forks into a different valley for every seed. Geography, environment, and data-storytelling pieces take it.

River erosion — steepest-descent flow line
Open in a new tab

08 · River erosion — steepest-descent flow line — Measured area 51.864% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fbmlineTorequestAnimationFrame

Where to look in the code

The measurement recorded a cumulative area of 51.86% — the widest mover in the set — with a peak frame area of 16.7% and movement in 23 of 23 frames.

function trace(ix) {
  var path = [ix];
  var x = ix % GW, y = (ix / GW) | 0;
  for (var s = 0; s < MAX_STEP; s++) {
    var best = -1, bestH = heights[y * GW + x];
    for (var dy = -1; dy <= 1; dy++) {
      var ny = y + dy; if (ny < 0 || ny >= GH) continue;
      for (var dx = -1; dx <= 1; dx++) {
        if (dx === 0 && dy === 0) continue;
        var nx = x + dx; if (nx < 0 || nx >= GW) continue;
        var h = heights[ny * GW + nx];
        if (h < bestH) { bestH = h; best = ny * GW + nx; }
      }
    }
    if (best < 0) break;                 // reached a basin — the river closes into a lake
    x = best % GW; y = (best / GW) | 0;
    path.push(best);
    if (y === GH - 1 || y === 0) break;  // left the map — reached the sea
  }
  return path;
}

09Starfield drift — noise-density star field

What you're seeing

Stars land as createImageData pixels only where 2D fbm crosses a threshold, so density follows the noise grain, and each star's brightness twinkles from a separately phased noise value. Night-theme portfolios and event countdown backgrounds carry it.

Starfield drift — noise-density star field
Open in a new tab

09 · Starfield drift — noise-density star field — Measured area 34.494% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataputImageDatafbm

Where to look in the code

The measurement recorded an average intensity of 179.8 — the deepest change of the nine boards — with a peak frame area of just 2.06%, yet a cumulative area of 34.49% and movement in 23 of 23 frames.

var D_SCALE = 0.09, D_FLOW = 0.32, D_THRESH = 0.60;    // density noise — the threshold stars are born at
var N_SCALE = 0.018, N_FLOW = 0.5;                     // nebula noise — low frequency, broad bands
var T_SCALE = 0.35, T_FLOW = 1.7;                      // twinkle noise — each star on its own phase

// density — a star lands only where fbm clears the threshold, so the star
// positions themselves follow the noise texture
var d = fbm(x * D_SCALE + 500.7, dy + 233.9, zd, 3);
if (d > D_THRESH) {
  // twinkle — a second noise, offset by the star's own coordinates, swings brightness
  var tw = fbm(x * T_SCALE + 811.2, y * T_SCALE + 47.6, sec * T_FLOW, 2);
  var edge = (d - D_THRESH) / (1 - D_THRESH);          // how far past the threshold (0..1)
  var glow = Math.min(1, edge * 1.6 + tw * 0.6);
  r = STAR_DIM[0] + (STAR[0] - STAR_DIM[0]) * glow;
  g = STAR_DIM[1] + (STAR[1] - STAR_DIM[1]) * glow;
  b = STAR_DIM[2] + (STAR[2] - STAR_DIM[2]) * glow;
  stars++;
}

How does a perlin noise example turn into terrain?

One function, three readings

Noise is nothing more than random numbers on a grid, smoothed so neighbouring values connect — and every board here is one of three readings of that one field. Read the value as height and ridges, dunes, and seas come out (01, 04, 06); cut the field at thresholds and contour rings, cloud edges, and star density appear (02, 03, 09); follow the lowest neighbour downhill and a river is traced (08) — seven springs walking a 46x28 grid, retraced from scratch every frame with no cache.

A table, and either a value or a dot product

The implementation underneath all nine is one seed-shuffled permutation table plus a random value parked at each grid corner (value noise) or a gradient vector dotted with the sample offset (the board 03 Perlin) — a handful of lines either way, which is the whole reason no external library is needed. The render probes hashed every board twice at seed 7 and once more at seed 91: identical hashes for the repeated seed, a different hash for the new one. Same number in, same terrain out — the seed input on every board is the living proof of that regeneration.

The third axis is the only thing that moves

Among procedural noise algorithms this is the smallest loop that still breathes: the frame clock advances the third noise axis, every pixel samples the same deterministic function, and the screen stays alive while the computation stays exact. Boards that animate octaves push each octave's phase at (i + 1) times the flow speed, which is why fine detail re-carves before the broad shapes do.

Where it breaks — the ridge that repeated itself

The 256-cell wraparound

The first build of board 01 drew a plausible mountain range, then drew the same range again where the screen wrapped. The permutation table holds 256 cells and coordinates are masked with & 255, so once a sampling position travels a full 256 cells the values return to where they started — the periodicity is inherent to the wrap, not a bug in the drawing code. Giving every octave a different scale smears the periods so the repeat mostly hides, but when the far and near layer offsets happened to land near multiples of the period, both silhouettes cycled at one visible spacing. The fix that survived was independent offsets per octave and per layer — near-irrational constants such as 13.7 and 241.3 in the source — so no two layers ever realign.

Contour lines glued to the band edges

The contour map broke differently: while the line thresholds shared edges with the five colour bands, every contour sat exactly on a band boundary, and the result read as an outline drawing, not a map. Setting the line steps (11) denser than the colour steps (5) and marking only pixels whose 8-neighbourhood crosses a grade is the structure that came out of that repair.

The finished source for all nine boards and the React ports sits in the password archive under this article, and the password is p2v2mgjz.

Three variants and accessibility

Knob Change What it does
Octave count 02 contour map, 5 octaves to 8 coarse grain splits into fine, closely spaced detail
Seed 09 starfield, seed 7 to 91 same rules, an entirely different constellation
Colour $terrain-mint to $terrain-peach a cold terrain tone turns to warm desert

Octave count

Stacking three more octaves on the contour map's fbm adds a layer at half amplitude each time, so valleys gain rims and the rings multiply — the grain that read coarse splits into fine, closely spaced detail.

Seed

Type 91 into the starfield's seed box and the same threshold rule scatters a completely different sky: the permutation table is rebuilt, so every placement decision lands elsewhere while the rule itself stays fixed.

Colour

Every board reads its palette from pastel token variables at the top of its scss; swapping $terrain-mint for $terrain-peach shifts the whole scene from cold terrain to warm desert without touching a line of math.

What reduced-motion keeps

All nine boards honour prefers-reduced-motion the same way: the animation loop never starts, and the board draws the single frame its seed chose — 01 a finished ridge silhouette, 02 a map with every contour drawn, 08 a river traced to its end. The seed input stays live in that still frame, so a new number redraws a different terrain instantly. The noise animation stops; the terrain remains.

FAQ

Do these perlin noise boards stay fast without a library?

Yes. The pixel boards (02, 03, 09) fix their buffers at 160x100 — 16,000 cells — and let upscale interpolation absorb the screen size, so the per-frame cost never grows with the window. All nine boards moved in 23 of 23 measured frames, and because frames advance on the timestamps requestAnimationFrame hands over, a slow device plays the same computation more slowly without work piling up.

Simplex noise vs perlin noise — which one does this code need?

Simplex noise replaces the square grid with simplices so fewer corners get interpolated; converting this set means swapping the grid access and interpolation inside vnoise for simplex coordinates and keeping every fbm, threshold, and tracing layer as is. It wins on cost and directional evenness in three dimensions and above, but for two-dimensional terrain and skies the value and Perlin noise in these boards already carry the scene.

Can I paste the source straight into a project?

Yes — the password archive holds vanilla and React versions of all nine boards, and each one pastes in and runs. Colours swap through the token variables at the top of each scss file, and feeding the same seed number to the input reproduces the same terrain every time.


References: Wikipedia: Perlin noise · MDN getImageData · MDN putImageData · MDN createImageData · MDN requestAnimationFrame · MDN prefers-reduced-motion

Enter the archive password

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