CSS Background Pattern Generator: 9 Seeded Scenes
A CSS background pattern generator turns one number into a drawing instead of an image file.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Noise field contour map
- 02 Voronoi cell tiles
- 03 Flow field line art
- 04 Rotational symmetry mandala
- 05 Truchet tile maze
- 06 Hex grid painting
- 07 Recursively split rectangles
- 08 Branching tree
- 09 Background made from a name
The order here isn't popularity. It follows where the randomness goes. The first three scatter it across the whole surface: heights on a lattice sliced into contour lines (01), points that each claim their nearest territory (02), and an angle stored at every point for a line to walk along (03). The middle three spend randomness once and let a rule copy the result: a single stroke turned twelve times (04), one tile laid at four angles (05), and cells colored by a probability that leans across the grid (06). The last three feed randomness into recursion: a rectangle cut and cut again (07), a branch that splits and splits again (08), and a background whose seed is a name rather than a number (09). If you want the backdrop to move on its own instead, see 9 CSS Background Animation; for the same SVG approach applied to numbers, see CSS Chart Design: 9 SVG Graphs.
01Noise field contour map
Put random values only on the corners of a coarse lattice and smooth the gaps with smoothstep, and you have a bumpy height map. Marching squares then traces six layers of "everything at this height," which is exactly how a topographic map is drawn. The preview measured 11.93% changed area, intensity 94.5, and 16/23 frames moving — a fit for the hero band of an about page.
// mulberry32 — one 32-bit integer seed always returns the same stream. The root of reproducible generation
function mulberry32(a) {
return function () {
a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
@keyframes ncScan {
0% { stroke-dashoffset: 1; animation-timing-function: $easing; }
38% { stroke-dashoffset: 0; animation-timing-function: linear; }
50% { stroke-dashoffset: 0; animation-timing-function: $ease-out; }
86% { stroke-dashoffset: -1; }
100% { stroke-dashoffset: -1; }
}
02Voronoi cell tiles
Scatter twenty-six points, let each one keep the area closer to it than to any other point, and you get facets that read like cracked glass. There's no need to pull in a computational geometry library: clip the outer rectangle with the perpendicular bisector between it and every other point, and whatever survives is that point's cell. Measured 19.07% changed area, intensity 72.5, 23/23 frames moving — good behind a pricing section.
// Clip a polygon by the perpendicular bisector of two points, keeping the half-plane on a's side (Sutherland-Hodgman)
function clip(poly, a, b) {
var mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
var dx = b[0] - a[0], dy = b[1] - a[1], out = [], i;
function side(p) { return (p[0] - mx) * dx + (p[1] - my) * dy; }
for (i = 0; i < poly.length; i++) {
var p = poly[i], q = poly[(i + 1) % poly.length], sp = side(p), sq = side(q);
if (sp <= 0) { out.push(p); }
if ((sp < 0 && sq > 0) || (sp > 0 && sq < 0)) {
var t = sp / (sp - sq);
out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
}
}
return out;
}
03Flow field line art
Store one angle at every point on the canvas, then take thirty short steps, each along the angle you're standing on at the time, and short straight moves add up to long curves. The fifty-four starting points come from a jittered grid rather than plain random draws, so the lines cover the frame instead of clumping in one corner. Measured 5.71% changed area, intensity 90.8, 23/23 frames moving — a full-bleed backdrop for a music or exhibition page.
// Start at one point and step along the angle stored at wherever you currently are
function walk(angleAt, x, y) {
var d = 'M' + x.toFixed(1) + ' ' + y.toFixed(1), i;
for (i = 0; i < STEPS; i++) {
var a = angleAt(x, y);
x += Math.cos(a) * STEP;
y += Math.sin(a) * STEP;
if (x < -8 || x > W + 8 || y < -8 || y > H + 8) { break; }
d += 'L' + x.toFixed(1) + ' ' + y.toFixed(1);
}
return d;
}
04Rotational symmetry mandala
Randomness touches exactly one thing in each ring: a single stroke reaching outward from the center, and the other eleven arms are that stroke rotated 30 degrees at a time. Symmetry is the cheapest way to spend almost no randomness and still get a figure. Three rings and 37 shapes gave 11.88% changed area, intensity 76.2, 23/23 frames moving — a splash screen for a meditation app, or the decorative ring on an invitation.
for (ring = 0; ring < RINGS; ring++) {
var d = stroke(rand, 62 - ring * 17), inner = '';
for (arm = 0; arm < ARMS; arm++) {
// Copy one stroke every 30 degrees — rotational symmetry is the cheapest way to spend randomness once and still get a figure
inner += '<path transform="translate(' + CX + ' ' + CY + ') rotate(' + (arm * 360 / ARMS) + ')" d="' + d + '"/>';
}
parts.push('<g class="md__ring md__ring--' + (ring % 2 ? 'b' : 'a') + '" style="--i:' + ring + '">' + inner + '</g>');
}
05Truchet tile maze
There's only one tile: two quarter-arcs, each joining the midpoints of two adjacent edges, dropped at 0, 90, 180, or 270 degrees. Curves cross cell borders on their own, and the grid stops looking like a grid. Sixty tiles gave 26.12% changed area, intensity 150.0, 22/23 frames moving — a background for a 404 page, or the texture behind a card grid.
for (row = 0; row < ROWS; row++) {
for (col = 0; col < COLS; col++) {
var turn = Math.floor(rand() * 4) * 90;
var tone = rand() < 0.28 ? 'b' : 'a';
// The outer g only places the tile (static). Putting a CSS transform on the inner animated g
// would override the transform attribute and the tile would lose its spot
parts.push('<g transform="translate(' + col * SIZE + ' ' + row * SIZE + ')">' +
'<g class="tr__tile tr__tile--' + tone + '" style="--i:' + (col * ROWS + row) + '">' +
'<path transform="rotate(' + turn + ' 12 12)" d="' + TILE + '"/></g></g>');
}
}
06Hex grid painting
Picking cell colors by pure chance looks like spilled salt, not a pattern. Mix the random draw with a probability that leans from left to right, and position survives in the result, so the color looks like it flows. Seventy-seven hexagons gave 25.93% changed area, intensity 84.4, 23/23 frames moving — an empty dashboard state, or the header band on a stats page.
// Six corners of a flat-top hexagon — every 60 degrees starting at 0
function hexPoints(cx, cy) {
var pts = [], k;
for (k = 0; k < 6; k++) {
var a = k * Math.PI / 3;
pts.push((cx + R * Math.cos(a)).toFixed(1) + ',' + (cy + R * Math.sin(a)).toFixed(1));
}
return pts.join(' ');
}
07Recursively split rectangles
Cut a big rectangle once, then cut each piece by the same rule; a function calling itself with the depth reduced by one is the whole algorithm. The part worth tuning is the bias: lean the coin toss toward cutting the longer side, or you end up with slivers. Twenty-seven pieces gave 14.92% changed area, intensity 77.2, 23/23 frames moving — cover art for a portfolio.
// Call yourself twice with the depth reduced by one. When a piece is too small to cut, keep it
function split(x, y, w, h, depth, rand, out) {
if (depth === 0 || (w < MIN * 2 && h < MIN * 2)) { out.push([x, y, w, h]); return; }
var cutDown = w > h ? rand() < 0.82 : rand() < 0.18; // lean toward cutting the longer side
var t = 0.32 + rand() * 0.36;
if (cutDown) {
split(x, y, w * t, h, depth - 1, rand, out);
split(x + w * t, y, w * (1 - t), h, depth - 1, rand, out);
} else {
split(x, y, w, h * t, depth - 1, rand, out);
split(x, y + h * t, w, h * (1 - t), depth - 1, rand, out);
}
}
08Branching tree
Same recursion as 07, except it reaches out instead of cutting in. Draw a branch, call yourself twice from its tip, and shrink length and width to roughly 0.7 of the parent each generation. Sixty-three branches gave 3.97% changed area, intensity 125.2, 16/23 frames moving — an illustration for an environmental page, or something that grows on a loading screen.
function branch(x, y, angle, len, width, depth, rand) {
var x2 = x + Math.cos(angle) * len, y2 = y + Math.sin(angle) * len;
var gen = DEPTH - depth;
var inner = '<line class="bt__line bt__line--' + (gen > 3 ? 'b' : 'a') + '" x1="' + x.toFixed(1) +
'" y1="' + y.toFixed(1) + '" x2="' + x2.toFixed(1) + '" y2="' + y2.toFixed(1) +
'" stroke-width="' + width.toFixed(2) + '"/>';
if (depth > 0) {
var spread = 0.3 + rand() * 0.34;
inner += branch(x2, y2, angle - spread, len * (0.7 + rand() * 0.12), width * 0.72, depth - 1, rand);
inner += branch(x2, y2, angle + spread, len * (0.7 + rand() * 0.12), width * 0.72, depth - 1, rand);
}
// --ox/--oy is this branch's root. CSS takes that point as transform-origin so the branch grows from there
return '<g class="bt__seg" style="--i:' + gen + ';--ox:' + x.toFixed(1) + 'px;--oy:' + y.toFixed(1) +
'px">' + inner + '</g>';
}
09Background made from a name
The seed here is text, not a number: fold the letters with FNV-1a into one 32-bit integer, hand that to the generator, and the same name always returns the same artwork. Only the left half is generated and the right half is a mirror of it, which is what makes a random grid read as a face. Sixty shapes gave 11.03% changed area, intensity 72.5, 23/23 frames moving — a profile header backdrop.
// FNV-1a: fold the characters one by one into a single 32-bit integer. The same text always gives the same number
function hash(text) {
var h = 2166136261, i;
for (i = 0; i < text.length; i++) {
h = Math.imul(h ^ text.charCodeAt(i), 16777619);
}
return h >>> 0;
}
Where it breaks — recursion has to nest on screen too
The first version of 08 laid all sixty-three branches out as siblings. Each branch had its true root coordinate and grew from that point, so the math was right and every gate passed. Then the poster came out with loose twigs floating in midair above a trunk that was no longer there. Each generation is delayed by 70ms, so during the folding half of the loop, the trunk (delay 0ms) collapses first while the outer twigs (delay 350ms) are still standing. That version measured 1.83% changed area, intensity 74.5, 18/23 frames moving (run/293/_수리전실측.json). The fix was one structural change: put the child branches inside the parent <g>. The parent's scale then carries its children along, and you can freeze the loop at any instant and still see a tree.
The same root cause creates a second trap — element count. Generative code grows without limit the moment you raise a grid number, and once every shape carries an animation-delay, a low-end 320px screen loses scrolling before it loses frames. So all nine have a ceiling, and we stepped through ten seeds each to count what actually comes out (run/293/_요소수.json).
| Item | Shapes at the default seed | Max over 10 seeds | What grows it |
|---|---|---|---|
| 01 contours | 6 | 6 | Contour layers (6); the 32×20 sample grid only adds detail inside a layer |
| 02 Voronoi | 26 | 26 | Number of points (26) |
| 03 flow field | 54 | 54 | Start-point grid 9×6 |
| 04 mandala | 37 | 37 | 3 rings × 12 arms + 1 center dot |
| 05 Truchet | 60 | 60 | Tile grid 10×6 |
| 06 hex grid | 77 | 77 | Honeycomb 11×7 |
| 07 recursive split | 27 | 32 | Depth 5 — random, so it varies by seed |
| 08 tree | 63 | 63 | Depth 5 (2⁶−1) |
| 09 name background | 60 | 60 | Filled cells of a 12×8 grid |
The only row that moves at all is 07, and it stops at 32. When you want a denser pattern, don't add shapes — draw each shape in more detail. Item 01 has six shapes and the busiest linework of the nine. The archive password for the nine files plus their React ports is 69g487dg, and everything from the marching-squares table to the Voronoi clipping runs as shipped.
Accessibility (reduced-motion)
All nine SVGs carry role="img" and an aria-label, and the label includes the seed currently on screen. A screen-reader user who presses "Regenerate" should hear what changed as a value, not as a vague "image." Color carries no information here — the tone steps are decoration, and the meaning of a pattern lives in its shapes and positions. The text contrast was measured rather than assumed: the seed label reads 11.16:1 on the ink stage, 5.5:1 on the orange stage (#ff4d1f), 7.27:1 on the paper and yellow stages, and every button label reads 17.11:1, so all of them clear the 4.5:1 AA threshold (run/293/_대비실측.json). Under prefers-reduced-motion: reduce, all nine stop animating but keep the finished pattern on screen. Asking for less motion isn't asking for the background to disappear. Every declaration inside the reduce block is marked !important. Here the reduce block re-declares the same selector as .nc.is-demo .nc__line further down the file, so it would win without the flag; the flag is there so that motion cannot come back if someone later layers a higher-specificity rule on top.
FAQ
Can the same seed produce a different picture in another browser?
No. mulberry32 uses only 32-bit integer operations (Math.imul, >>>) and never accumulates floating-point error, so every engine walks the same sequence. Math.cos and Math.sin feed the coordinates, and their last digits can differ between implementations, but toFixed(1) rounds to one decimal place, so the path strings come out identical.
Why bother when a background image file already works?
Because the file no longer has to exist. Handing a thousand people a thousand different backdrops no longer means producing a thousand images; item 09 takes a username and draws one on the spot. And nothing is locked in: the "Copy SVG" button hands you the generated markup, so you can pick one seed you like and freeze it into a static asset.
Why animate with CSS instead of redrawing every frame?
A requestAnimationFrame painter drops whole stretches of animation when the tab goes to the background or the main thread gets busy. Generating once and handing the motion to @keyframes pushes the work down to the compositor, which keeps it smooth during scrolling and makes prefers-reduced-motion a one-line switch. stroke-dashoffset is an animatable property, as the MDN stroke-dasharray reference documents.