---
title: Generating Vasarely in Code
date: '2026-07-04'
description: 'How I rebuilt Victor Vasarely''s Vega-Nor as a pure, deterministic TypeScript function, with OKLab paint ramps, an area-conserving spherical warp, and colorways generated from a seed. Every image here comes from a seed.'
author: Pablo Fernandez
tags:
  - Generative Art
  - SVG
  - OKLab
  - TypeScript
  - Vasarely
source: 'https://www.pablofr.com/blog/generative-vasarely-in-code'
---
[Victor Vasarely](https://en.wikipedia.org/wiki/Victor_Vasarely) spent the 1960s bending flat grids of squares until they looked like spheres. [*Vega-Nor*](https://buffaloakg.org/artworks/k196929-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](https://en.wikipedia.org/wiki/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](https://developer.mozilla.org/en-US/docs/Web/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.*

## The shape of the problem

It all comes down to one call:

```typescript:vasarely.ts /renderVasarelySvg/
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.

```
", bg: "#bae6fd", bold: true }]}>{`
  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](https://en.wikipedia.org/wiki/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](https://bottosson.github.io/posts/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:

```typescript:ramp.ts {8,12} /hexToLab/
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 row
  warped   │· ·  ·    ·       ·    ·  · ·│  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:

```typescript:bulge.ts /strength/ /sliver/
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](https://en.wikipedia.org/wiki/Smoothness), 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:

```typescript:hash.ts {2,3,4} /Math.imul/
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:

## 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:

```typescript:palette-from-seed.ts /vegaNorPaletteFromSeed/
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`:

## 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.

```typescript:palette.ts /makeVegaNorScheme/ /vegaNorPaletteFromSeed/
// 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.*

*Esmeralda: emerald and deep-blue fields, cream and coral accents.*

*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.*

Drop the warp entirely and you're left with the flat "alphabet plastique" folklore grid, which is pure [combinatorics](https://en.wikipedia.org/wiki/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.*

## 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.
