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

Generative Art JavaScript: 9 Patterns That Grow

With generative art JavaScript, one seed gives the same picture on every redraw. 02 reaches veins toward scattered attractors, 03 inserts nodes as a line

Generative art hands the brush to a set of rules written in advance, and the picture grows out of what those rules allow. Each of the nine scenes below is plain 2D canvas math with no library, no WebGL and no image file, so a finished picture comes from nothing more than a seed number and a few named constants.

Watching Nine Pictures Grow From a Single Seed on One Canvas

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

Every tile waits 200ms on a blank board before it starts growing, then holds the finished picture for 900ms (1,400ms in 09) and moves on to the next seed. Once you click a canvas or a button, or press Enter, R or Space on a canvas, 01 to 08 stop cycling and stay on the current seed once it has fully grown, while 09 carries on to new seeds even after you touch it.

Generative Art JavaScript: 9 Patterns — Flow Lines, Leaf Veins, Folding Line, Circle Packing, Right-Angle Cracks, Matching Tiles, Tree Rings, Lichen Spread and a Growing Hero

The first three scenes are about how a line decides where to go next. In 01 the distance to other lines makes a line halt, in 02 attractor points set the direction of each branch, and in 03 the growing node count folds the line onto itself. Scenes 04 to 06 fill an area instead, with swelling circles, cracks that split at right angles and tiles that agree along their edges. 07 and 08 copy natural things that pile up over time, rings and lichen, and 09 stacks the math from 01, 02 and 04 into one first-screen scene driven by one seed.

All nine draw into a <canvas> with a fixed 480×300 coordinate space. Motion comes from a loop that adds up requestAnimationFrame timestamps and spends them on fixed 16ms ticks, running at most six ticks in one frame. The stylesheet has zero @keyframes rules, and its only transition is the background color of the buttons.

How much each growing scene changed during the capture

A headless browser shot 24 frames across 2 seconds, which leaves 23 gaps between neighboring shots, and the frames column counts how many of those gaps showed changed pixels.

Scene Cumulative area Moving frames Mean intensity
01 Spaced flow lines 28.0% 17/23 97.0
02 Attractor leaf veins 9.8% 16/23 60.2
03 Node-inserting folded line 15.5% 23/23 81.9
04 Inflating circle packing 44.2% 23/23 92.8
05 Right-angle cracks 42.4% 17/23 56.6
06 Edge-matching tiles 50.6% 23/23 83.0
07 Tree rings 35.8% 18/23 138.6
08 Edge-cell lichen 75.5% 21/23 112.3
09 Three-layer hero 39.0% 23/23 73.4

Only 02 falls under 10%. Its nodes are thin 2.6px strokes that appear only inside the leaf outline, one growth pass per tick (ITER_PER_FRAME 1), which likely keeps the number of changed pixels low. 08 covers the most at 75.5%, most likely because it paints 4px cells at 60 per tick across all 9,000 cells of the board. 05 is done after 100 ticks, or 1,600ms, so its holding period likely lands inside the capture, which would explain why it moves in only 17 frames.

Which screens suit a picture that grows

For the first screen of a landing page, 09 can go in as is, and behind a text-heavy page the even grain of 01 is the quieter choice. Because a given seed yields the same picture each time, feeding in a post number or product number gives every page its own header art that stays put on reload, with no image file to host.

Waiting screens want a scene whose progress you can see. 08 spreads its colonies until the board is full, and 06 settles cell after cell like lifting fog, now and then collapsing once and filling again. Plant and eco brands fit the veins of 02 or the rings of 07, portfolios or 404 pages that want a paper or plaster feel fit the cracks of 05, and banners that need ornament packed edge to edge fit the circles of 04.

When You Study Generative Art Examples, Where Does Chance End and Rule Begin?

Scatter random dots evenly across a screen and the density is the same wherever you look, so there is no grain or clump for the eye to follow. In these nine scenes, chance only picks a starting point or chooses one candidate out of several, and the shape comes from a rule that says when growth must stop. A line in 01 halts once a point of another line comes within 4px, a circle in 04 freezes when its gap to a neighbor shrinks to 2px, and a crack in 05 ends the instant it meets a crack running at another angle.

The structure shows most clearly in what gets thrown away. 04 discarded 29,021 of 30,588 spot draws (94.9%) and planted circles only in the spots that survived. In the finished picture of 01, the closest distance between two different lines was exactly 4.00px, which is LINE_SEPARATION 8 multiplied by SEP_TEST 0.5, so however hard chance shook things, not one point slipped below the distance the rule set.

That is why a generative pattern maker of your own starts with the stopping condition rather than the choice of random generator. The nine scenes stop on distance (01 and 04), on attractors running out (02), on a node cap (03), on a different angle (05), on no undecided cell remaining (06), on a radius cap (07) and on no empty cell remaining (08). Each of those differences turns straight into a difference in the picture.

A Seeded Random Generator That Repeats the Same Picture, and a Grid That Lets Growing Lines Find Neighbors Fast

None of the nine sources calls Math.random, Date.now or new Date. Every random number comes from the one makeRng function below, which stirs a 32-bit integer with Math.imul and >>> and returns a value from 0 up to but not including 1, so the same seed replays the same sequence.

    // Seeded random — the same seed gives the same sequence, and so the same picture again (32-bit integer mixing)
    function makeRng(seedValue) {
        let s = seedValue >>> 0;
        return function () {
            s = (s + 0x6d2b79f5) >>> 0;
            let t = s;
            t = Math.imul(t ^ (t >>> 15), t | 1);
            t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
        };
    }
    // Next seed — each Redraw changes just one integer (five digits between 10007 and 99995)
    function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }

The Redraw button and Enter use nextSeed to make the next five-digit seed, and the Same seed button and R regrow the current seed from an empty board. Growing 01 with seeds 47101, 13161 and then 47101 again and hashing every point coordinate gives 1433911356, 239204140 and 1433911356, a numeric check that one seed means one picture and a new seed means a new one. Growth is split into 16ms ticks rather than frames, and the work per tick is a constant (01 STEPS_PER_FRAME 70, 06 COLLAPSE_PER_FRAME 2 and so on), so the scenes likely grow in the same order on screens with different refresh rates. That repeatability is the gap between generative art using code and a one-off random scatter that cannot be reproduced later.

Finding neighbors is the job of grid buckets. The screen is cut into cells (8px in 01, 60px in 02, 12px in 03, 34px in 04), every point goes into its own cell, and distance checks run only against points in the surrounding cells. A fully grown 01 holds 8,386 points, so checking each step against every point would have meant more than 8,000 comparisons per step.

01Flow Lines That Stop 4px Short of Any Other Line

Each corner of a 12px grid gets one direction from noise up front, and a line walks 2px at a time in both directions, steering by the direction half a step ahead, until another line's point comes within 4px. The spots 8px to either side of a finished line queue up as the next starting candidates, so the screen fills with evenly spaced grain that suits an about page that wants contour lines or a sense of wind. Seed 47101 finished in 135 ticks with 155 lines totaling 16,462px, and the capture showed 28.0% cumulative area, intensity 97.0 and 17/23 frames.

Lines That Follow a Flow Field While Keeping Their Distance
Open in a new tab

01 · Lines That Follow a Flow Field While Keeping Their Distance — Measured area 28.044% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLOW_NOISE_SCALELINE_SEPARATIONSTEP_LENGTH
    // Is another point within radius d of (x, y)? Points right before and after on the same line (within twice the spacing) do not count
    function flowTooClose(f, x, y, d, line, ord) {
        const skip = Math.ceil((f.o.sep * 2) / f.o.step);
        const d2 = d * d;
        return bucketNear(f.grid, x, y, d, function (id) {
            if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
                return false;
            }
            const dx = f.px[id] - x;
            const dy = f.py[id] - y;
            return dx * dx + dy * dy < d2;
        });
    }
            // One step — midpoint method that walks along the direction half a step ahead (cuts the error that drifts outside curves)
            b -= 1;
            const h = f.o.step * f.dir;
            const d1 = flowDir(f, f.x, f.y);
            const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
            const nx = f.x + d2[0] * h;
            const ny = f.y + d2[1] * h;
            const nord = f.ord + f.dir;
            if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
                // If the front is blocked, go back to the start and grow the other way; if that is blocked too, the line is done
                if (f.dir === 1) {
                    f.dir = -1;
                    f.ord = 0;
                    f.x = f.sx;
                    f.y = f.sy;
                }
                else {
                    flowEndLine(f);
                }
                continue;
            }

02Leaf Veins Branching Toward 900 Scattered Attractors

The seeded generator scatters 900 attractors inside a leaf shape, each attractor picks the nearest node within 60px, that node sprouts a 2.6px node along the average of its pulls, and any attractor within 6px of a new node is removed. Branch thickness follows the square root of each node's descendant count, thinning from the stem out to the leaf edge, so the growth itself tells a story on the about screen of a plant or eco brand. Seed 47102 removed every attractor by tick 103 and stopped at 794 nodes and 71 branch tips, and at 9.8% cumulative area, intensity 60.2 and 16/23 frames it was the only one of the nine under 10%.

Leaf Veins Reaching Toward Scattered Attractor Points
Open in a new tab

02 · Leaf Veins Reaching Toward Scattered Attractor Points — Measured area 9.836% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

ATTRACTOR_COUNTINFLUENCE_RADIUSKILL_DISTANCE
            for (let i = 0; i < n; i += 1) {
                if (!cnt[i]) {
                    continue;
                }
                let dx = sx[i];
                let dy = sy[i];
                let L = Math.hypot(dx, dy);
                // If attractors pull from opposite sides and the average is near 0, this node skips growing this time
                if (L < 0.2) {
                    continue;
                }
                // A tiny jitter — breaks symmetry so nodes do not stack on the same spot
                dx = dx / L + (v.rng() - 0.5) * 0.12;
                dy = dy / L + (v.rng() - 0.5) * 0.12;
                L = Math.hypot(dx, dy) || 1;
                veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
            }
        }
        const leftBefore = v.left;
        veinKill(v, n);
        // Stop after VEIN_STALL_LIMIT ticks in a row of growth with no attractor removed — so it does not circle after attractors it cannot reach
        v.stall = v.left === leftBefore ? v.stall + 1 : 0;
        if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
            v.done = true;
        }

03A Closed Line That Folds Each Time Nodes Are Inserted

Every node on a closed line is drawn toward the midpoint of its two neighbors, held to them by a spring that tries to keep 4px, and pushed off by any node other than those neighbors that comes within 12px. Each tick adds nodes to edges longer than 5px and to edges picked by the seeded generator, and with no room left inside its oval fence the line folds into coral or brain-like ridges, a good header for science or medical content that wants an organic texture. Seed 47103 reached the 2,600-node cap at tick 214 with a perimeter of 5,611px, and the render measured 15.5% cumulative area, intensity 81.9 and 23/23 frames.

A Line That Folds as It Grows by Inserting Nodes
Open in a new tab

03 · A Line That Folds as It Grows by Inserting Nodes — Measured area 15.466% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

REPEL_RADIUSMAX_EDGE_LENGTHALIGN_FORCE
            for (let k = 0; k < 2; k += 1) {
                const j = nbs[k];
                const dx = xs[j] - x;
                const dy = ys[j] - y;
                const d = Math.hypot(dx, dy) || 1e-6;
                const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
                fx += dx * pull;
                fy += dy * pull;
            }
            bucketNear(grid, x, y, R, function (j) {
                if (j === i || j === pv || j === nt) {
                    return false;
                }
                const dx = x - xs[j];
                const dy = y - ys[j];
                const d2 = dx * dx + dy * dy;
                if (d2 >= R * R || d2 < 1e-9) {
                    return false;
                }
                const d = Math.sqrt(d2);
                const push = ((R - d) / R) * REPEL_FORCE;
                fx += (dx / d) * push;
                fy += (dy / d) * push;
                return false;
            });
    // Split long edges — shifting the start index every tick, insert a node in the middle of any edge longer than MAX_EDGE_LENGTH
    function splitEdges() {
        let made = 0;
        const n0 = xs.length;
        let i = splitFrom % n0;
        for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
            const j = (i + 1) % xs.length;
            const dx = xs[j] - xs[i];
            const dy = ys[j] - ys[i];
            if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
                // Just slightly off the middle — folds form only once symmetry breaks
                const jit = (lrng() - 0.5) * 0.4;
                xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
                ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
                made += 1;
                i += 1;
            }
            i = (i + 1) % xs.length;
        }
        splitFrom = i + 37;

04Circle Packing That Inflates Until Only a 2px Gap Is Left

Each tick draws 60 random spots and plants a 1px circle only where a 2px gap remains to every other circle and the border, then grows each planted circle by 0.8px per tick and bakes it into the artwork the moment it runs out of room. Small circles filling the gaps between large ones play out in plain view, which suits event posters or promo banners that want ornament packed edge to edge. Seed 47104 ran for 565 ticks and covered 46.1% of the screen with 1,567 circles, and the capture read 44.2% cumulative area, intensity 92.8 and 23/23 frames.

Circle Packing Where Circles Inflate Until They Touch
Open in a new tab

04 · Circle Packing Where Circles Inflate Until They Touch — Measured area 44.177% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GROW_RATESPAWN_ATTEMPTSCIRCLE_GAP
    // Can a circle of radius r sit at (x, y)? Checks the border, neighbor circles in the grid buckets (center distance < r + neighbor r + gap), then blocked spots, in that order
    function packFree(p, x, y, r, self) {
        const g = p.o.gap;
        if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
            return false;
        }
        const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
            if (j === self) {
                return false;
            }
            const dx = p.cx[j] - x;
            const dy = p.cy[j] - y;
            const lim = r + p.cr[j] + g;
            return dx * dx + dy * dy < lim * lim;
        });
        if (hit) {
            return false;
        }
        return !p.o.blocked(x, y, r + g);
    }
        // Grow — if the radius one step larger still fits, grow; otherwise this circle stops here
        const next = [];
        for (let k = 0; k < p.growing.length; k += 1) {
            const i = p.growing[k];
            const nr = p.cr[i] + p.o.grow;
            if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
                p.cr[i] = nr;
                next.push(i);
            }
            else {
                onStop(i);
            }
        }
        p.growing = next;
        if (p.fails >= p.o.failTicks && p.growing.length === 0) {
            p.done = true;
        }
        return p.done;

05Right-Angle Cracks That Halt at a Crack of Another Angle

A board stores the angle of the crack that passed through each pixel, a crack walks 0.5px at a time over empty pixels or pixels within 5° of its own angle and stops at any other angle, and every stopped crack sends out two new ones from a point on an existing crack at a right angle shaken by up to 4°. Beside each crack it measures the empty run to the next crack and stamps 10 faint grains of sand inside it, which gives the gaps a grain that suits a portfolio backdrop with a paper or plaster feel. Seed 47105 hit the 320-crack cap at tick 100 with 14,337 pixels carrying an angle, and the capture read 42.4% cumulative area, intensity 56.6 and 17/23 frames.

Cracks That Spread by Branching at Right Angles
Open in a new tab

05 · Cracks That Spread by Branching at Right Angles — Measured area 42.388% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

CRACK_MAXBRANCH_ANGLE_JITTERSAND_GRAINS
    // Sand along the normal — follow empty pixels sideways from the crack to measure the distance to another crack (or the border), then stamp faint grains inside it
    function paintSand(c) {
        const nxv = Math.cos((c.t + 90) * DEG);
        const nyv = Math.sin((c.t + 90) * DEG);
        let d = 1;
        for (; d < SAND_REACH; d += 1) {
            const px = Math.floor(c.x + nxv * d);
            const py = Math.floor(c.y + nyv * d);
            if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) {
                break;
            }
        }
        c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
        for (let i = 0; i < SAND_GRAINS; i += 1) {
            const k = (d * c.g * i) / SAND_GRAINS;
            actx.fillStyle = c.sand[i];
            actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
        }
    }
    function moveCrack(c) {
        c.x += CRACK_STEP * Math.cos(c.t * DEG);
        c.y += CRACK_STEP * Math.sin(c.t * DEG);
        c.steps += 1;
        const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
        const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
        if (cx < 0 || cx >= W || cy < 0 || cy >= H) {
            c.alive = false;
            return;
        }
        const i = cy * W + cx;
        const g = cgrid[i];
        if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
            if (g >= EMPTY) {
                marks.push(i);
            }
            cgrid[i] = c.t;
            paintSand(c);
            actx.fillStyle = rgba(INK, 0.85);
            actx.fillRect(c.x, c.y, 1, 1);
        }
        else if (c.steps > START_GRACE) {
            // Hit a crack with a different angle — stop here
            c.alive = false;
        }

06Matching Tiles That Keep Only Pieces Whose Edges Agree

Every cell of a 24×15 board starts with all 18 tiles as candidates, each tile carrying grass, road or stream edges, and the cell with the fewest left is settled by a weighted seeded pick whose effect a queue carries through the neighbors' candidates until nothing changes. Unsettled cells are drawn as fog that thickens with the share of candidates still open, so you watch the constraints spread, which fits a map-style game screen or the wait screen of a route-finding service. Demo seed 66110 hit a cell with zero candidates at 912ms, refilled the board once from scratch and completed all 360 cells at 3,664ms, and the render gave 50.6% cumulative area, intensity 83.0 and 23/23 frames.

Tiles That Fill In by Keeping Only Pieces That Match Their Neighbors
Open in a new tab

06 · Tiles That Fill In by Keeping Only Pieces That Match Their Neighbors — Measured area 50.553% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TILE_EDGESTILE_WEIGHTSCOLLAPSE_PER_FRAME
                const j = nr * COLS + nc;
                if (fixedTile[j] >= 0) {
                    continue;
                }
                // Edge types this cell's candidates offer toward direction d — only neighbor tiles matching those types remain
                let allow = 0;
                for (let t = 0; t < NT; t += 1) {
                    if (cand[i] & (1 << t)) {
                        allow |= FIT[d][edgeOf(t, d)];
                    }
                }
                const nm = cand[j] & allow;
                if (nm === cand[j]) {
                    continue;
                }
                if (nm === 0) {
                    return false;
                }
                cand[j] = nm;
                if (bits(nm) === 1) {
                    fix(j, 31 - Math.clz32(nm));
                }
                queue.push(j);
    // Pick the cell with the fewest candidates (ties broken by a seeded order) and choose its tile by weighted random
    function collapseOne() {
        let best = -1;
        let bestScore = 1e9;
        for (let i = 0; i < cand.length; i += 1) {
            if (fixedTile[i] >= 0) {
                continue;
            }
            const s = bits(cand[i]) + tie[i];
            if (s < bestScore) {
                bestScore = s;
                best = i;
            }
        }
        if (best < 0) {
            return true;
        }

07Tree Rings That Close Without a Seam After a Full Turn

Rings of a different width each year stack out to a 138px radius, each ring reads its angle as a point on a circle in noise space so the radius wobbles within ±8%, and that circle moves by 0.07 per ring so neighboring rings resemble each other. Wide years are painted light and narrow years as dark, thick bands, and each new ring swells into place over 5 ticks, a match for wood or furniture brands and company history pages. Seed 47107 drew all 24 rings in 120 ticks with a 0px radius gap between angle 0 and 2π, and its intensity of 138.6 was the highest of the nine, with 35.8% cumulative area and 18/23 frames.

Tree Rings That Add One Wavy Layer at a Time
Open in a new tab

07 · Tree Rings That Add One Wavy Layer at a Time — Measured area 35.837% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

RING_SPACINGRING_NOISE_AMPRING_SEGMENTS
    // Radius of ring k at angle a — reads the angle as a point on a circle in noise space, so a full turn closes on the same value
    function ringR(k, a, base) {
        const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
        const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
        return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
    }
    function ringPoints(k, base) {
        const out = new Float64Array(RING_SEGMENTS * 2);
        for (let j = 0; j < RING_SEGMENTS; j += 1) {
            const a = (j / RING_SEGMENTS) * TAU;
            const r = ringR(k, a, base);
            out[j * 2] = TRUNK_CX + Math.cos(a) * r;
            out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
        }
        return out;
    }

08Lichen Colonies Claiming One Edge Cell at a Time

Seven colonies are planted on a board of 9,000 cells, each 4px square, and in turn each one pulls a cell from its edge list with the seeded generator and claims it if it is still empty, swapping the pulled slot with the last entry before removing it so a long list does not slow down in step with its length. Early cells are painted dark and late ones light, and a dark 1px line marks every edge shared with another colony, which suits a dashboard backdrop that splits regions or team areas. Seed 47108 filled all 9,000 cells in 150 ticks and left 732 borders, and at 75.5% cumulative area it was the widest of the nine, with intensity 112.3 and 21/23 frames.

Lichen Colonies Spreading One Edge Cell at a Time
Open in a new tab

08 · Lichen Colonies Spreading One Edge Cell at a Time — Measured area 75.494% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

COLONY_COUNTCELLS_PER_FRAMEAGE_FADE
    function claim(i, c) {
        owner[i] = c;
        const x0 = (i % COLS) * CELL;
        const y0 = Math.floor(i / COLS) * CELL;
        // Fade color by birth order — the early center is dark, the late edge is light
        actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
        actx.fillRect(x0, y0, CELL, CELL);
        born += 1;
        fresh.push(i);
        const col = i % COLS;
        const row = Math.floor(i / COLS);
        const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
        for (let k = 0; k < 4; k += 1) {
            const j = nb[k];
            if (j < 0) {
                continue;
            }
            if (owner[j] < 0) {
                if (!(inFront[j] & (1 << c))) {
                    inFront[j] |= 1 << c;
                    fronts[c].push(j);
                }
            }

09A Hero That Grows Flow Lines, Veins and Circles From One Seed

One seed is mixed with a lane number so that flow lines, veins, circles and noise each draw from their own random stream, and the layers start growing in turn at 0ms, 480ms and 960ms, with circles keeping 2px clear of vein nodes. Once all three finish, the scene holds for 1,400ms, a copy of the scene alone is set aside, and that copy fades out over the next seed's scene across 700ms, so the board drops straight into a landing hero. One demo cycle (47109 → 76513) took 909 ticks and 14,544ms, and the capture recorded 39.0% cumulative area, intensity 73.4 and 23/23 frames.

A Hero Backdrop Where Three Layers Grow From One Seed
Open in a new tab

09 · A Hero Backdrop Where Three Layers Grow From One Seed — Measured area 39.016% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HERO_SEEDLAYER_STAGGER_MSHOLD_MS
    // A different random stream per lane from the same seed — the three layers do not share numbers, so their patterns stay independent
    function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
    const VEIN_CX = 316;
    const VEIN_CY = 124;
    function insideCrown(x, y) {
        const dx = (x - VEIN_CX) / 150;
        const dy = (y - VEIN_CY) / 100;
        return dx * dx + dy * dy < 1;
    }
    function buildFlow(s) {
        return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
    }
    function grow() {
        fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
        if (!fl.done) {
            flowStep(fl, HERO_FLOW_STEPS, flowSeg);
        }
        if (age >= LAYER_STAGGER_MS && !vn.done) {
            veinStep(vn);
        }
        if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
            packStep(pk, function () { });
        }
        const all = fl.done && vn.done && pk.done;
        if (!all) {
            return false;
        }

Tens of Thousands of Points, New Seeds That Look the Same, a Resized Screen — Where Growing Patterns Break

Tick counts and totals in this section come from loading the functions of each vanilla index.html into Node and running them again, while figures for a first build were measured the same way on an earlier version during development. Millisecond timings were taken in Node on our test machine and will differ from device to device, so tick counts are the fairer comparison.

Neighbor checks eat the budget as points multiply

01 piles up 8,386 points, 04 around 1,500 circles and 03 as many as 2,600 nodes. Without the grid, 04 would compare (60 spot draws plus the growing circles) against all 1,500 circles every tick, and the screen would likely stutter as the count climbs. 03 has work per tick that rises with its node count, and at the 2,600-node cap the heaviest tick measured in Node took 2.0ms. In 08, pulling a cell out of the middle of the edge list with splice would shift everything behind it, so the cell trades places with the last one and leaves from the end.

A flow line that blocks itself on its first step

If 01 counted the points just behind it on the same line, its own previous point would sit within 4px from the very first step and the line would halt at once. That is why flowTooClose skips points on the same line within 8 steps before and after, a stretch twice the spacing long. At the center of a swirl a line could circle forever, so it is cut off at 700 points (MAX_LINE_POINTS).

Veins pinned between attractors pulling in opposite directions

The first build of 02 normalized even an average direction close to zero and kept growing from it, and seeds 47102 and 27672 then failed to clear their last 2 attractors, ran all the way to MAX_ITER 400 and swelled to 1,102 nodes. Two attractors on opposite sides likely tugged one node back and forth in place. Resting any node whose average is shorter than 0.2 for that tick, and stopping after 40 ticks in a row with no attractor removed, brought the finish down to 103 ticks. The 6px kill distance sits above the 2.6px node length, likely so a new node cannot hop past an attractor and swing back and forth around it.

A folding line that does not lengthen on pushing and splitting alone

The first version of 03 had nothing but long-edge splitting and repulsion, and after 8,000 ticks it still sat at 47 nodes. The push between neighbors points along the line, so those forces likely canceled out. The line began to lengthen only after a spring holding neighbors at 4px and growth nodes inserted into seeded edges were added. Inserting with splice shifts the index of every later node by one, so i jumps one extra place right after each insert.

A late finish check means waiting on a nearly identical picture

04 uses FAIL_TICKS to decide how many ticks in a row with no new circle end the run. With 24 the run took 1,070 ticks, and with 8 it ended at 565. The spots left open in between take circles of only 1 to 2px, so the picture barely differs. When two circles grow in the same tick, the one checked later tests against the already enlarged radius of the first, so they do not overlap.

A crack with nowhere to start

The first build of 05 looked for a new crack's starting point with 1,200 tries at random pixels, and because the opening board held only 12 marked pixels, not one try hit a marked pixel and the run ended with 0 cracks. It now picks straight from marks, the list of pixels that already carry an angle. The first 4 steps after a start are a grace period so a new crack does not stop instantly on its parent's pixels. Angle differences are not wrapped at 360°, so −90° and 270° count as different cracks even though they point the same way, a limit that remains in the source.

Bridge tiles would keep the board from collapsing

06 has no bridge tile where a road crosses a stream. An earlier board with 2 bridge tiles had 0 contradictions across 200 seeds, while the current board without them collapses once in 7 of 400 seeds. That suggests a place where a road and a stream can meet leaves no dead-end cell, and demo seed 66110 was picked from those seven so the refill is visible. With no backtracking, the whole board starts over, so a bigger board also means a costlier restart. Picking among equal candidate counts in index order would fill rows from the top left, so every cell adds a seeded tie-breaker, tie.

The 7.40px step from reading the angle as a straight line

Change ringR in 07 so it feeds the angle straight in as the noise x, and at the same amplitude the radius at 0 and at 2π differs by 7.40px, putting a step in every ring. Reading the angle as a point on a circle brings the end of the turn back to the same noise position as the start, so the gap is 0px. The amplitude scales with the radius and reaches ±11px on the outer ring at 138px, so where a year is only 2.67px wide, neighboring rings could cross. Each ring's noise circle moves by just 0.07 from the last, which likely keeps that crossing almost invisible in the actual picture.

Why a new seed can look much like the old one

A new seed moves things around but keeps the character. In 02, seeds 21080 and 27672 also cleared every attractor by tick 103 (85 and 103 branch tips), in 04, seeds 36918 and 94117 rejected 95.2% and 94.2% of spots, less than one percentage point from the 94.9% of 47104, and 08 fills 9,000 cells at 60 per tick, so any seed finishes in 150 ticks (732, 821 and 975 borders). To change the impression, the constants in the variant table below move it faster than a new seed.

Three layers disturbing each other in the hero

If 09 shared one random stream across its three layers, changing one flow line setting would likely reshuffle the vein and circle patterns drawn after it, so laneRng gives each layer its own lane. Circles start at 960ms and avoid only the vein nodes grown by then, so veins that keep growing afterward can still overlap a circle, a gap left in the source. Copying the previous scene straight off the visible canvas would carry the stats box in the lower right into the fade, so snapshot redraws the scene alone with drawScene.

When screen size or pixel ratio changes

Coordinates inside the canvas stay at 480×300, and only the outer box resizes through width: min(92vw, calc(160vh - 12.8vw)) and aspect-ratio: 480 / 300. A phone therefore grows the same picture from the same seed. The real pixel count is multiplied by the device ratio but capped at 2 with Math.min(window.devicePixelRatio || 1, 2). Resizing a canvas wipes what it holds, so when the ratio changes, fitCanvas parks the artwork on a temporary canvas and copies it back at the new size.

If you would like to grow the nine vanilla folders with your own seed and your own colors, type 9rhueet8 when the downloaded zip asks for its password, and React versions running the same math are in the same archive.

What Changes When You Shift Line Spacing, Attractor Radius and Repel Radius?

Variant Value Feel
Widen the line spacing 01 LINE_SEPARATION 8 → 16 On seed 47101 the line count drops from 155 to 79 and total length from 16,462px to 8,372px (0.51 times), and growth that took 135 ticks ends in 66. Doubling the gap leaves room for only as many lines as the area divided by the spacing, so length roughly halves and the grain turns loose and airy.
Widen the attractor radius 02 INFLUENCE_RADIUS 60 → 120 Nodes go from 794 to 815 and branch tips from 71 to 81, while growth still takes 103 ticks, so the picture is close to unchanged. Total growth time measured in Node rose from 24.6ms to 40.0ms. With 900 attractors packed into the leaf, the nearest node is likely already found within 60px.
Increase the repel radius 03 REPEL_RADIUS 12 → 22 At the same 120 ticks, mean thickness (twice the area divided by the perimeter) rises from 7.5px to 13.0px, width from 171px to 271px and area from 12,036px² to 29,297px². Tight folds become broad, loose loops.

Of the three rows, 01 and 03 visibly change the grain of the picture, while the attractor radius in 02 leaves the picture nearly as it was and mostly adds computation. 02 is a reminder to measure both whether the picture really changes and how much the cost grows before you enlarge a radius.

Patterns moved by CSS keyframes instead are in generative pattern backgrounds, and areas split by nearest point are in Voronoi diagram patterns. Crystals that grow by random walks sticking together are in snowflake crystal growth, trees grown by rewriting rule strings are in fractal tree on canvas, and scenes made from per-pixel math sit in 9 canvas scene effects.

One Finished Frame as a Still Image — Accessibility for Reduced Motion and Background Art

When the operating system asks for reduced motion (prefers-reduced-motion), none of the nine runs its requestAnimationFrame loop. finishNow() instead grows that seed to the end within a cap of 6,000 ticks (GROW_GUARD) and paints the finished picture at once, and switching or regrowing seeds with Enter, R or the buttons simply swaps in the next finished picture. 09 stays on one fully grown three-layer frame, with no crossfade and no next seed. A change listener on matchMedia watches the setting, so switching it with the page open takes effect right away.

The keyboard reaches all nine as well. Each canvas has tabindex="0" and an aria-label that describes the picture and its keys, and with focus inside, Enter draws a new seed, R regrows the same seed and Space pauses or resumes. Only those three keys have their default action blocked, so Tab and other keys pass through. Redraw, Same seed and Pause are real <button> elements, the pause button reports its state through aria-pressed, and both canvases and buttons show a :focus-visible outline.

The status line stays quiet with aria-live="off" while the demo runs on its own, switches to polite the first time a person interacts, and changes its text only when the phase changes, such as growing, fully grown or paused. In 06 the growing status adds a restart count only after at least one restart, while the fully grown message carries it even at zero. Counts that change every tick, such as lines, nodes or circles, are painted in the box at the lower right of the canvas rather than in the status line, so a screen reader is not fed a number on every frame. When the picture sits behind text purely as decoration, removing the buttons and key hints and hiding the canvas from screen readers is kinder to people listening.

Questions to Ask Before Laying Generative Art Behind Your Site

Can this generative pattern maker show a different picture on every visit?

The first seed is one line, SEED0 in 01 to 08 and HERO_SEED in 09. The sources leave out the clock and Math.random on purpose so that a seed keeps giving the same picture, so to vary it per visit, generate that number on the page side and pass it in, and keep the number if you ever want to see that picture again. A number fixed to each page, such as its post number, gives every page a different picture that still comes back unchanged on reload.

Where do I change the colors to match my brand?

In all nine sources the colors sit near the top of the script as the INK, PAPER and PAL constants, written as RGB arrays, and each of the three feeds the drawing calls. Changing only those array values recolors the lines, nodes and circles while the math and shapes stay as they are. The rounded corners (border-radius) and shadow of the stage box follow the tokens in style.scss.

Will it run without strain on a phone?

The work per tick has a ceiling. 01 is held to 70 steps, 04 to 8 new circles and 08 to 60 cells per tick by constants, a frame runs at most 6 ticks, and the pixel ratio is capped at 2. 03 at its 2,600-node cap had a heaviest tick of 2.0ms in Node, but that figure comes from our test machine, so on a slow device, lowering MAX_NODES or the per-tick caps is the safer move.

Enter the archive password

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