Generating Vasarely in Code

By Pablo Fernandez5 min read

Victor Vasarely spent the 1960s bending flat grids of squares until they looked like spheres. Vega-Nor (1969) is the one everyone knows: a grid of colored cells that swells out of the canvas, the cells squeezing into thin slivers at the edges while the color runs across the whole thing as one smooth field. It's hand-painted op-art, but it's also, pretty clearly, an algorithm.

So I wrote that algorithm. The whole engine is one pure function: pass it some options, get back an SVG string. Same input, same pixels, every time. There's no Math.random, no canvas, nothing to run. Every image on this page is generated from a seed, and if you reload the page they come back identical, because the seed is the entire state.

Vega-Nor (1969), rebuilt from an 18×18 grid, a single area-conserving bulge, and hand-sampled OKLab paint ramps.
Vega-Nor (1969), rebuilt from an 18×18 grid, a single area-conserving bulge, and hand-sampled OKLab paint ramps.

The shape of the problem#

It all comes down to one call:

TSvasarely.ts
TypeScript
export function renderVasarelySvg(opts: VasarelyOptions = {}): string {
  // 1. lay down a regular grid of `cells × cells` cells
  // 2. warp every vertex through the bulge(s)
  // 3. for each cell: sample the light field, pick a paint ramp,
  //    quantise the color, emit nested <path> layers
  // 4. return one <svg> string
}

Three separate systems feed that loop: color, geometry, and light. Each one has a trick that makes the result look painted instead of plotted.

color: seed ─▶ hash ─▶ palette ─┐geometry: grid ─▶ bulge warp ──────┼─▶ sample each cell ─▶ nested <path> ─▶ <svg>light: radial light field ──────┘
The three decoupled systems, color, geometry and light, feeding one cell loop that emits a single SVG string.

Color lives in OKLab, and it bands#

Vasarely didn't have a gradient tool. He mixed a few discrete pots of paint and stepped between them, so the color transitions band, and that banding is a big part of the look. Interpolating in sRGB would muddy the mid-tones (the classic magenta-grey you get when you cross-fade red into blue), so every ramp is interpolated in OKLab instead, where a straight line between two colors actually looks straight to the eye.

A ramp is just a list of (position, hex) stops. Sampling walks to the right segment and mixes in Lab space:

TSramp.ts
TypeScript
export type Ramp = Array<[number, string]>;
 
function rampAt(ramp: Ramp, t: number, dL = 0): string {
  const tc = clamp(t, 0, 1);
  let i = 0;
  while (i < ramp.length - 2 && ramp[i + 1][0] < tc) i++;
  const [t0, h0] = ramp[i];
  const [t1, h1] = ramp[i + 1];
  const f = (tc - t0) / (t1 - t0);
  const a = hexToLab(h0);
  const b = hexToLab(h1);
  return labToHex([a[0] + (b[0] - a[0]) * f + dL, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f]);
}

The continuous result then gets quantised down to a small number of steps. Nine steps looks like paint; forty-eight looks like a smooth dome. It's a one-line knob that slides the whole piece between silkscreen and airbrush.

The bulge warps the sheet, it isn't a ball on top#

This is the part that took longest to get right. A naive sphere warp just scales cells up near the centre, but then the grid overflows and the far cells stop being regular. Vasarely's sphere conserves area instead: cells grow in the middle, shrink to slivers at the edge, and the grid far away stays exactly where it was.

regular │· · · · · · · ·│ equal cells across the rowwarped │· · · · · · · ·│ slivers at the rim, wide cells at the centre
The area-conserving bulge: a regular row of cells becomes wide at the centre and thin slivers at the rim, while the cell count stays the same.

Each bulge is described declaratively, and the engine solves for a radial magnification profile whose integral is the actual warp:

TSbulge.ts
TypeScript
export interface Bulge {
  cx: number; cy: number;         // centre, in fractional canvas coords
  r?: number;                     // radius as a fraction of min(w, h)
  strength?: number;              // >1 bulges out, <0 makes a concave dip
  blend?: number;                 // width of the zone relaxing back to flat
  sliver?: number;                // radial scale kept by the edge slivers
}
 
// The calibrated Vega-Nor sphere: big dome, steep edge, readable slivers.
const bulge: Bulge = { cx: 0.5, cy: 0.5, r: 0.47, strength: 1.85, blend: 0.7, sliver: 0.35 };

The edge, where the dome meets the flat mesh, is a V shape in the magnification profile. Steepen one side and it looks like a near-vertical wall; soften it and the sphere eases back into the plane. The whole thing is C¹ continuous, so no cell ever kinks.

Determinism: hashes, never Math.random#

The only randomness in the piece is cosmetic: a small per-vertex wobble and a per-cell tone flicker, so the grid doesn't look laser-cut. But random and reproducible have to coexist, otherwise you can't cache the image or reproduce a design. So every random value is a hash of the seed and the cell coordinates:

TShash.ts
TypeScript
function hash2(seed: number, i: number, j: number): number {
  let h = (seed ^ (i * 0x9e3779b1) ^ (j * 0x85ebca6b)) >>> 0;
  h = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

Same seed, same hash, same picture. And since the seed is the whole state, a colorway is really just a string. Type one in and it draws the piece:

Vasarely vega-nor (seed: pablofr)

Same seed and pattern always give the same artwork, so anything you make here is reproducible.

A palette from a string#

Before a colorway can dress the sphere, it has to exist. The seed doesn't pick colors at random. It indexes a set of hue poles calibrated on 35 of Vasarely's works, then maps them onto the six roles the Vega-Nor recipe needs: two cool field hues, two mid-tone cores, and two hot lattice accents (one of them the amber that keeps showing up in his paintings). Only the lightness gets retargeted per role, so the calibrated hues survive:

TSpalette-from-seed.ts
TypeScript
export function vegaNorPaletteFromSeed(seed: string): VegaNorPalette {
  const pal = vasarelyPaletteFromSeed(seed);   // corpus-weighted hue poles
  const goldHue = hexToOklch(pal.light).H;     // Vasarely's recurring amber
  return {
    fieldLeft:  atL(pal.accentHue, 0.26),      // dark, saturated cool fields
    fieldRight: atL(pal.coolHue, 0.26),
    coreLeft:   atL(pal.accentHue, 0.58, 0.13),// mid-tone cores
    coreRight:  atL(pal.coolHue, 0.58, 0.13),
    latticeA:   atL(goldHue, 0.78, 0.16),      // the bright gold lattice
    latticeB:   atL(pal.warmHue, 0.6, 0.19),   // the hot accent
  };
}

Here's what a few strings turn into. Each strip reads fieldLeft, coreLeft, latticeA, latticeB, coreRight, fieldRight:

"pablofr"
"pablofr"
"vega-nor"
"vega-nor"
"hello-world"
"hello-world"
"miles-davis"
"miles-davis"

One recipe, many colorways#

Because geometry, light, and color are decoupled, the painting stays fixed while the palette is free. Six anchor colors (two field hues, two cores, two lattice accents) regenerate the whole Vega-Nor color system, keeping every structural rule intact: rings borrow the opposite half's hue, sector lattices sink into the field's cool, corners run warm.

TSpalette.ts
TypeScript
// hand-picked colorway
const fuego = makeVegaNorScheme({
  fieldLeft: "#14265e", fieldRight: "#5e0f26",
  coreLeft: "#5a80a8", coreRight: "#b86a52",
  latticeA: "#d9b02a", latticeB: "#d5402a",
});
 
// or let a seed pick a fresh, corpus-calibrated one
const mine = makeVegaNorScheme(vegaNorPaletteFromSeed("pablofr"));

Here's the same sphere three ways: navy and burgundy under gold and vermilion, deep green under cream and coral, violet and teal under amber and magenta.

Fuego: navy and burgundy fields, gold and vermilion lattices.
Fuego: navy and burgundy fields, gold and vermilion lattices.
Esmeralda: emerald and deep-blue fields, cream and coral accents.
Esmeralda: emerald and deep-blue fields, cream and coral accents.
Púrpura: violet and teal fields under amber and magenta lattices.
Púrpura: violet and teal fields under amber and magenta lattices.

Beyond the sphere#

The bulge is a reusable lens, so it can bend other grids too. The Vega color-dome study is a field of concentric targets with a palette-swapping centre diamond. Run it through the same spherical warp and the whole grid swells.

Vega classic: the concentric-target study, bent through the Vega-Nor lens.
Vega classic: the concentric-target study, bent through the Vega-Nor lens.

Drop the warp entirely and you're left with the flat "alphabet plastique" folklore grid, which is pure combinatorics of a cell color plus a centred shape, and a different picture for every seed.

Folklore, with no warp: the flat combinatorial grid of a cell color and a centred shape.
Folklore, with no warp: the flat combinatorial grid of a cell color and a centred shape.

Why bother#

A painting that's secretly a function turns out to be a genuinely useful thing to have. It scales to any resolution without pixels, it regenerates from a seed, and it turns a color decision into a six-hex change. Vasarely was basically programming with paint, so handing the brush back to the machine feels about right.

And that's the generative art behind Vega-Nor.

Related articles

A Film as a Column of Color

The third generative-art engine in the series. The problem of turning a seed into something that reads like a film reduced to color, a little of the maths behind it (seeded noise, OKLCH, a texture model), and a demo you can drive.

Giving an Inbox to Each Agent

Password resets, invoices, and support replies still arrive by email, and my agents live on HTTP. Mailingest is the Rust mail platform I built so that an inbox costs one API call: an address whose mail shows up as a signed webhook.

Building a Better WhatsApp SDK

whatsapp-web.js runs a whole headless Chromium to act like a phone. The other door skips the browser and speaks WhatsApp's native protocol over a plain WebSocket, then hands you a raw socket and a firehose of events. whatsweb is the thin layer I built to make that socket feel like a bot.