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

9 Reaction Diffusion Simulations, No Library

A reaction diffusion simulation computes two chemicals on a grid until spots, stripes and mazes emerge on their own.

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

The order below is not decorative — it follows where the feed rate f pushes the pattern. Low removal keeps dots isolated (01, 04, 05); lowering f stretches dots into lines (02, 06); a middle band kinks those lines into a maze (03, 07); raising the removal rate k shrinks everything into round chambers (08); and at the lowest f the growth never stops forking (09). Measured cumulatively, the board that moved across the most canvas is 01 (52.878%), while the single frame that covered the most at once belongs to 03 Maze (50.879%). If you liked seeding a canvas once and letting math draw the rest, CSS background patterns and canvas noise terrain live next door.

01Spot array — dots rise where reaction wins

A living dot texture for hero backgrounds and cover-illustration seeds: a uniform V field seeded with a handful of U drops grows into an evenly spaced array of dots.

Spot array — dots rise where reaction wins
Open in a new tab

01 · Spot array — dots rise where reaction wins — Measured area 52.878% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Why do the dots settle at even spacing?

// One cell update — 5-point Laplacian diffusion + Gray-Scott reaction terms
var lapU = u[yC + xL] + u[yC + xR] + u[yT + x] + u[yB + x] - 4 * uc;
var lapV = v[yC + xL] + v[yC + xR] + v[yT + x] + v[yB + x] - 4 * vc;
var uvv = uc * vc * vc;
var nu = uc + DU * lapU - uvv + F * (1 - uc);
var nv = vc + DV * lapV + uvv - (F + K) * vc;
u2[i] = nu < 0 ? 0 : (nu > 1 ? 1 : nu);   // saturation clamp — without it the grid diverges
v2[i] = nv < 0 ? 0 : (nv > 1 ? 1 : nv);

Wherever U meets V the two combine and make more V (the uvv term), while V removes itself at rate (F+K)*v — every dot that grows starves its neighbors of the U around it, and the survivors settle into the same spacing once each dot's zone of influence meets the next, with this board pre-running 1,500 steps before the first paint so it starts already at a mature array (measured: 52.88% area, intensity 99.3, 23/23 frames).

02Stripe bands — one parameter turns dots into lines

Concept art for animal-print textures and stripe-generator seeds: lowering only the feed rate on the same grid stretches dots until they merge into bands.

// Seed — three sine-wave horizontal bands, phase and amplitude drawn from the seed
function seedField(sd) {
  var rng = mulberry32(sd >>> 0);
  u.fill(1); v.fill(0);
  var n = 3;
  for (var s = 0; s < n; s++) {
    var cy = (s + 0.5) * GH / n;
    var ph = rng() * 6.28;
    var amp = 1 + rng() * 2;
    var th = 2 + rng();
    for (var y = 0; y < GH; y++) {
      for (var x = 0; x < GW; x++) {
        var line = cy + Math.sin(x * 0.22 + ph) * amp;
        if (Math.abs(y - line) <= th) { v[y * GW + x] = 0.5; u[y * GW + x] = 0.25; }
      }
    }
  }
}
Stripe bands — one parameter turns dots into lines
Open in a new tab

02 · Stripe bands — one parameter turns dots into lines — Measured area 49.959% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

When U is plentiful every dot defends its own patch, but as the feed rate drops each dot starves along one edge and stretches that way until neighboring stretches merge into a stripe. Dragging the slider left on this board turns the spot array from 01 straight into bands in real time (measured: 49.96% area, intensity 102.3 — the sharpest contrast in this set, 23/23 frames).

03Maze — stripes kink into corridors in the middle ground

Game-map textures and static background fills: in the parameter band between spots and stripes, lines kink and fork into a maze.

// Seed — twenty small U patches scattered by the seed's rng
function seedField(sd) {
  var rng = mulberry32(sd >>> 0);
  u.fill(1); v.fill(0);
  var n = 20;
  for (var s = 0; s < n; s++) {
    var cx = 2 + Math.floor(rng() * (GW - 6));
    var cy = 1 + Math.floor(rng() * (GH - 4));
    var w = 1 + Math.floor(rng() * 4);
    var h = 1 + Math.floor(rng() * 3);
    for (var y = cy; y < Math.min(GH, cy + h); y++) {
      for (var x = cx; x < Math.min(GW, cx + w); x++) {
        v[y * GW + x] = 0.5; u[y * GW + x] = 0.25;
      }
    }
  }
}
Maze — stripes kink into corridors in the middle ground
Open in a new tab

03 · Maze — stripes kink into corridors in the middle ground — Measured area 52.86% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

At a feed rate between the spot and stripe regimes, a line's growing end never closes off — it keeps running until it meets a neighboring line, kinks, and forks again, leaving corridors behind (measured: 52.86% cumulative area, second only to 01 by a hair — and first on single-frame peak coverage at 50.879%, 23/23 frames).

04Coral growth — the reaction front reaches outward from a seed

Loading moments and organic-growth intros: pour U in one big blob at the center and the reaction front crawls outward like coral or moss.

// Seed — one central blob with a noisy, uneven edge
function seedField(sd) {
  var rng = mulberry32(sd >>> 0);
  u.fill(1); v.fill(0);
  var cx = GW / 2 + (rng() - 0.5) * 6, cy = GH / 2 + (rng() - 0.5) * 4;
  var rr = 5.5 + rng() * 2;
  for (var y = 0; y < GH; y++) {
    for (var x = 0; x < GW; x++) {
      var dx = x - cx, dy = (y - cy) * 1.15;
      var d = Math.sqrt(dx * dx + dy * dy);
      var wob = rr * (0.7 + 0.5 * rng());
      if (d < wob) { v[y * GW + x] = 0.5; u[y * GW + x] = 0.25; }
    }
  }
}
Coral growth — the reaction front reaches outward from a seed
Open in a new tab

04 · Coral growth — the reaction front reaches outward from a seed — Measured area 41.844% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Since U only survives at the outer edge of the blob, the front is forced to grow outward into the untouched U field, leaving a V wake behind it that reads as the body of coral or moss (measured: 41.84% area, 17/23 frames — because the growth advances at a steady rate, the frame-to-frame change concentrates in the front band itself).

05Sheep fleece — several seeds collide and blend

Texture backgrounds and illustration fills: fourteen seeds scattered across the canvas grow into patches that collide and blend into a clumpy texture.

// Seed — fourteen dots scattered widely, coordinates from the seed
function seedField(sd) {
  var rng = mulberry32(sd >>> 0);
  u.fill(1); v.fill(0);
  for (var n = 0; n < 14; n++) {
    var cx = 5 + rng() * (GW - 10), cy = 4 + rng() * (GH - 8);
    var rr = 2 + rng() * 1.6;
    for (var y = Math.max(0, cy - rr | 0); y <= Math.min(GH - 1, cy + rr + 1); y++) {
      for (var x = Math.max(0, cx - rr | 0); x <= Math.min(GW - 1, cx + rr + 1); x++) {
        var dx = x - cx, dy = y - cy;
        if (dx * dx + dy * dy < rr * rr) { v[y * GW + x] = 0.5; u[y * GW + x] = 0.25; }
      }
    }
  }
}
Sheep fleece — several seeds collide and blend
Open in a new tab

05 · Sheep fleece — several seeds collide and blend — Measured area 50.932% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Scattering many seeds means each patch that grows pushes back against its neighbors and forms a boundary between them, and the resulting web of boundaries reads as the lumpy, irregular texture of fleece or cloud puffs (measured: 50.93% area, 23/23 frames).

06Zebra stroke — narrow grid with vertical relaxation bias

Brand vertical stripes and character patterns: a tall narrow grid with diffusion biased horizontally packs vertical stripes densely, like a zebra's flank.

// Seed — 4 to 6 vertical bands placed at seed-drawn x positions
function seedField(sd) {
  var rng = mulberry32(sd >>> 0);
  u.fill(0); v.fill(0);
  for (var p = 0; p < GW * GH; p++) u[p] = 0.98 + rng() * 0.04;
  var nbars = 4 + Math.floor(rng() * 3);
  for (var n = 0; n < nbars; n++) {
    var cx = 6 + rng() * (GW - 12);
    var rr = 2.5 + rng() * 1.5;
    for (var x = Math.max(0, cx - rr | 0); x <= Math.min(GW - 1, cx + rr + 1); x++) {
      var dx = x - cx;
      if (dx * dx < rr * rr) {
        for (var y = 0; y < GH; y++) v[y * GW + x] = 0.4;
      }
    }
  }
}
Zebra stroke — narrow grid with vertical relaxation bias
Open in a new tab

06 · Zebra stroke — narrow grid with vertical relaxation bias — Measured area 45.926% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Which way the stripes align follows whichever direction diffuses faster. This board stretches the grid tall and gives the Laplacian's horizontal term a 1.2× weight, which packs the vertical bands as tightly as a zebra's side (measured: 45.93% area, 23/23 frames).

07Wave ripple — the whole pattern drifts

Backgrounds for meditation and music apps: feeding a settled stripe pattern a constant trickle of tiny nudges makes the whole texture drift like a slow wave.

// Micro injection — eight sources trace Lissajous paths and pour in U
function ripple() {
  for (var j = 0; j < SRC_N; j++) {
    var ph = 1.7 + j * 2.4;                       // each source gets its own phase
    var cx = (GW / 2 + Math.cos(t * ph) * GW * 0.36) | 0;
    var cy = (GH / 2 + Math.sin(t * (0.9 + j * 0.8)) * GH * 0.36) | 0;
    for (var dy = -SRC_RAD; dy <= SRC_RAD; dy++) {
      for (var dx = -SRC_RAD; dx <= SRC_RAD; dx++) {
        if (dx * dx + dy * dy > SRC_RAD * SRC_RAD) continue;
        var i = ((cy + dy + GH) % GH) * GW + ((cx + dx + GW) % GW);
        U[i] = 1;
        var nv = V[i] + SRC_BOOST;
        V[i] = nv > 1 ? 1 : nv;                   // the clamp catches any overshoot
      }
    }
  }
}
Wave ripple — the whole pattern drifts
Open in a new tab

07 · Wave ripple — the whole pattern drifts — Measured area 36.247% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

A settled stripe pattern left alone does nothing at all — it is a fixed point of the equations. The eight sources push against the line at each injection point, and that push propagates along the line so the whole texture drifts like a slow wave (measured: 36.25% area, intensity 30.4, 23/23 frames).

08Cell membrane — borders thicken and shimmer

Science-content illustrations and cell-concept UI: raising the removal rate shrinks the pattern into round cell-like chambers.

// Quiver — fourteen sources orbit the membrane and stimulate its border
function quiver() {
  for (var j = 0; j < SRC_N; j++) {
    var ph = 2.3 + j * 1.9;
    var cx = (GW / 2 + Math.cos(t * ph) * GW * 0.34) | 0;
    var cy = (GH / 2 + Math.sin(t * (1.3 + j * 0.9)) * GH * 0.32) | 0;
    for (var dy = -SRC_RAD; dy <= SRC_RAD; dy++) {
      for (var dx = -SRC_RAD; dx <= SRC_RAD; dx++) {
        if (dx * dx + dy * dy > SRC_RAD * SRC_RAD) continue;
        var i = ((cy + dy + GH) % GH) * GW + ((cx + dx + GW) % GW);
        U[i] = 1;
        var nv = V[i] + SRC_BOOST;
        V[i] = nv > 1 ? 1 : nv;
      }
    }
  }
}
Cell membrane — borders thicken and shimmer
Open in a new tab

08 · Cell membrane — borders thicken and shimmer — Measured area 31.474% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

A higher removal rate k burns off V faster, so the pattern shrinks itself down to round chambers as small as the equations allow. The border of each chamber never fully stops — it keeps shimmering at the boundary where reaction and removal balance, which is the tell that separates it from a static drawing (measured: 31.47% area, 23/23 frames).

09Splitting fingers — growing tips keep forking

Infographic backgrounds and growth-concept staging: at a very low feed rate every growing tip forks in two as it meets resistance.

// Seed — a jagged horizontal line of V at the bottom edge, pre-run to a growing state
function build(seed) {
  var rng = mulberry32(seed >>> 0);
  for (var i = 0; i < GW * GH; i++) { U[i] = 1; V[i] = 0; }
  var y0 = GH - 6;
  for (var x = 2; x < GW - 2; x++) {
    var gap = rng() < 0.30;                       // gaps make each seed's split points different
    if (gap) continue;
    V[y0 * GW + x] = 1;
    if (rng() < 0.6) V[(y0 + 1) * GW + x] = 1;
    if (rng() < 0.3) V[(y0 - 1) * GW + x] = 1;
  }
  for (var m = 0; m < RIP_STEPS; m++) step();
}
Splitting fingers — growing tips keep forking
Open in a new tab

09 · Splitting fingers — growing tips keep forking — Measured area 34.187% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

At a very low feed rate a growing tip can't push into a neighboring line's territory, so it forks in two to go around, and each fork keeps forking as it hits resistance again. Unlike coral in 04, the tips never die here, so the forking keeps filling the canvas until it runs out of room (measured: 34.19% area, 23/23 frames).

How does a reaction diffusion model become pattern? — Gray-Scott two chemicals

Every board in this set runs on one idea. The Gray-Scott reaction diffusion model puts two chemicals, feed U and catalyst V, on a grid and computes diffusion (spreading to neighbors), reaction (U+2V→3V), and feed/removal (rates f and k) every step. This is what "Turing pattern" actually means: neither chemical makes a pattern by itself, but because the two diffuse at different rates, a flat starting field is unstable — tiny fluctuations grow and settle into a periodic pattern instead of smoothing out. Sweeping the single feed rate f through spots, stripes, mazes, chambers and forking growth works because the shape of that instability itself changes with the parameter, and the same math is the textbook explanation for why animal skins carry the patterns they do. The grid stays fixed at 64×40 cells (48×72 for the tall board, 96×60 for 07-09) and the display size is handled entirely by upscaling, so the computation cost never depends on window size — and because the seed re-derives the same sequence of random draws every time, the same seed always reproduces the same pattern while a different seed reproduces a different one, which is exactly what the seed input and feed-rate slider on every board let you verify.

Where it breaks — a mature grid that killed the screen

The first board to break was 05 Sheep fleece: a pattern that grew nicely simply stopped, leaving only seven moving frames in a two-second capture. The cause turned out to be structural — once its parameters are fixed, Gray-Scott always converges to a mathematically static state, so the larger the pre-run maturation, the more likely the very first frame is already dead; a measured area of 25% meant a pattern that grew for a frame or two and then froze solid. Fixing it made things worse before they got better: injecting U to keep the pattern alive pushed V toward 1 at the injection point, and the neighboring cells' Laplacian then dragged U negative, flipping the sign of the uvv term and sending the whole grid to NaN within roughly a hundred steps — a screen gone solid black. Both bugs shared one fix: clamping every updated value back into [0, 1] after each step (the last two lines in the 01 code block), combined with injecting gently from several moving sources instead of one large dose. After that fix all nine boards move across 23 of 23 sampled frames. The finished source for all nine boards, vanilla and React alike, is in the zip below the article, and the password to open it is 54nx2m3f.

Three variants and accessibility

Name Change One-line feel
Feed rate f 02 Stripe bands f 0.045 → 0.030 stripes break back into spots
Seed 01 Spot array seed 7 → 91 same rule, a completely different dot layout
Color $turing-mint → $turing-peach the cool teal reaction field turns warm peach

Accessibility is handled the same way on every board. When a visitor has prefers-reduced-motion set to reduce, the rAF loop never starts and each board paints exactly one static frame drawn from the seed — 01 shows the finished spot array, 02 a settled stripe pattern, 04 a coral front reaching about halfway out. The seed input and feed-rate slider both stay live even on this static path, so changing the seed redraws a different single frame and moving the slider swaps to a different pattern family's static frame instead. Turning the animation off never turns off the fact that a computation produced the pattern.

FAQ

Can a reaction diffusion simulation without a library actually perform well?

Yes. The grid is fixed at 64×40 (2,560 cells) and even 8-14 solver steps per frame keep the per-frame cost to a few hundred additions and multiplications, well inside a 60fps budget — and since the display is an upscaled low-res buffer, resizing the window never increases the computation. All nine boards measured 23 of 23 moving frames, and because playback follows the time that rAF hands over, a slow device just plays slower without accumulating backlog.

How far can I push a Turing pattern generator with this code?

The feed-rate slider and seed input are wired into every board, so sliding f from 0.014 (09 Splitting fingers) up to 0.060 (08 Cell membrane) walks through spots, stripes, mazes and chambers on a single board. The two diffusion constants DU/DV (1.0/0.5) and the removal rate k sit as constants at the top of each source file, so changing them lets you experiment with how the diffusion-speed gap itself changes stripe thickness.

Can I just copy the source code?

Yes. The zip below the article holds the vanilla and React version of all nine boards, ready to paste and run. Swap colors through the pastel token variables at the top of each scss file, and typing a number into the seed input reproduces the same pattern every time that same seed is used.

Further reading

Enter the archive password

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