All articles
22 min read

Op Art: How to Create Mesmerizing Optical Illusions With Code

op artoptical illusioncreative codingJavaScriptgenerative artcanvasmoiré pattern

Op art — short for optical art — is a style of visual art that uses geometric patterns, contrast, and repetition to create the illusion of movement, vibration, depth, or warping on a flat surface. Pioneered in the 1960s by artists like Bridget Riley, Victor Vasarely, and Jesús Rafael Soto, op art exploits the quirks of human visual perception to make static images appear to move and breathe.

For creative coders, op art is a goldmine. Every optical illusion reduces to mathematics: sine waves, interference patterns, perspective tricks, and carefully tuned contrast. The patterns are simple to describe algorithmically but produce effects that look genuinely impossible. A few lines of code can create illusions that trick every brain that sees them.

In this guide we build eight interactive op art simulations, progressing from classic checkerboard warps to generative compositions that combine multiple illusion techniques. Every example is self-contained, runs on a plain HTML Canvas with no external libraries, and stays under 50KB. For more pattern techniques, see the geometric art guide, the math art guide, or the tessellation art guide.

Setting up

Every example uses this minimal HTML setup:

<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
// ... example code goes here ...
</script>

Paste each example into the script section. All code is vanilla JavaScript with the Canvas 2D API.

1. Checkerboard warp: bending a flat grid

The simplest op art illusion: a checkerboard that appears to bulge outward from the center. The trick is applying a radial distortion to the grid coordinates before deciding whether each pixel is black or white. Your brain interprets the curved grid lines as a 3D surface, even though every pixel is flat.

function draw(time) {
  const img = ctx.createImageData(W, H);
  const d = img.data;
  const cx = W / 2, cy = H / 2;
  const t = time * 0.001;
  const freq = 12; // grid frequency
  const bulge = 0.3 + 0.15 * Math.sin(t); // animated bulge strength

  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      // Normalized coords centered at origin
      let nx = (x - cx) / cx;
      let ny = (y - cy) / cy;
      const r = Math.sqrt(nx * nx + ny * ny);

      // Radial bulge distortion
      const distortion = 1 + bulge * Math.exp(-r * r * 2);
      nx *= distortion;
      ny *= distortion;

      // Checkerboard pattern
      const check = (Math.floor(nx * freq) + Math.floor(ny * freq)) % 2;
      const v = check === 0 ? 0 : 255;

      const i = (y * W + x) * 4;
      d[i] = d[i + 1] = d[i + 2] = v;
      d[i + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The Math.exp(-r*r*2) creates a Gaussian bump at the center — coordinates near the middle get stretched more than coordinates at the edges. The animation gently pulses the bulge strength, making the surface appear to breathe. Try changing freq to adjust the grid density, or replace the Gaussian with Math.sin(r * 5 - t * 2) for a ripple effect.

2. Concentric pulse: rings that radiate outward

Concentric rings of alternating black and white that appear to expand endlessly from the center. This is one of the most hypnotic optical illusions — the rings are actually static in radius, but a phase shift creates the illusion of perpetual outward motion.

function draw(time) {
  const img = ctx.createImageData(W, H);
  const d = img.data;
  const cx = W / 2, cy = H / 2;
  const t = time * 0.002;
  const rings = 20;

  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const dx = x - cx, dy = y - cy;
      const r = Math.sqrt(dx * dx + dy * dy);

      // Concentric rings with outward phase shift
      const wave = Math.sin(r * rings / cx * Math.PI - t);

      // Sharp threshold for crisp black/white
      const v = wave > 0 ? 255 : 0;

      const i = (y * W + x) * 4;
      d[i] = d[i + 1] = d[i + 2] = v;
      d[i + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The phase shift (-t) in the sine function makes each ring appear to move outward over time. The sharp threshold (wave > 0) keeps the edges crisp, which maximizes the optical illusion. Try replacing the threshold with const v = (wave * 0.5 + 0.5) * 255 for a softer, gradient version — you will notice the illusion of movement weakens because the brain needs hard edges to track.

3. Moiré interference: two grids, one illusion

A moiré pattern appears when two regular grids overlap at slightly different angles or frequencies. The interference creates large-scale phantom patterns that seem to shimmer and flow, even though each individual grid is perfectly regular. Moiré patterns appear in everyday life: overlapping window screens, fabric folds, even TV scanlines.

let mouseX = W / 2, mouseY = H / 2;
c.addEventListener('mousemove', e => {
  const rect = c.getBoundingClientRect();
  mouseX = (e.clientX - rect.left) * W / rect.width;
  mouseY = (e.clientY - rect.top) * H / rect.height;
});

function draw(time) {
  const img = ctx.createImageData(W, H);
  const d = img.data;
  const t = time * 0.0005;

  // Grid 1: fixed concentric circles from center
  const cx1 = W / 2, cy1 = H / 2;
  // Grid 2: concentric circles following mouse
  const cx2 = mouseX, cy2 = mouseY;
  const freq = 0.05; // line spacing

  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const r1 = Math.sqrt((x - cx1) ** 2 + (y - cy1) ** 2);
      const r2 = Math.sqrt((x - cx2) ** 2 + (y - cy2) ** 2);

      // Two concentric circle patterns
      const g1 = Math.sin(r1 * freq * Math.PI * 2) > 0 ? 1 : 0;
      const g2 = Math.sin(r2 * freq * Math.PI * 2) > 0 ? 1 : 0;

      // XOR combination reveals interference
      const v = (g1 ^ g2) * 255;

      const i = (y * W + x) * 4;
      d[i] = d[i + 1] = d[i + 2] = v;
      d[i + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The XOR operation between two concentric circle grids creates dramatic interference fringes that shift as you move the mouse. The phantom curves you see are not drawn anywhere in the code — they emerge purely from the interaction between two simple patterns. This is the essence of moiré: complexity from the superposition of simplicity. Move the mouse slowly to see the fringes glide smoothly; move it quickly to see them shatter and reform.

4. Rotating spiral: the illusion that never stops

A spiral that appears to perpetually expand or contract, even though it is actually rotating at a constant speed. This is related to the Plateau spiral illusion: after staring at a rotating spiral for 30 seconds and looking away, stationary objects appear to shrink or expand. The code version is mesmerizing on its own.

function draw(time) {
  const img = ctx.createImageData(W, H);
  const d = img.data;
  const cx = W / 2, cy = H / 2;
  const t = time * 0.001;
  const arms = 6; // number of spiral arms
  const twist = 3; // how tightly the spiral winds

  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const dx = x - cx, dy = y - cy;
      const r = Math.sqrt(dx * dx + dy * dy);
      const angle = Math.atan2(dy, dx);

      // Spiral: angle + log(r) creates logarithmic spiral
      // Rotation: subtract time for animation
      const spiral = Math.sin(arms * (angle + Math.log(r + 1) * twist - t));

      // Distance fade for clean edges
      const fade = Math.min(1, r / (cx * 0.9));
      const v = spiral > 0 ? (fade * 255) | 0 : 0;

      const i = (y * W + x) * 4;
      d[i] = d[i + 1] = d[i + 2] = v;
      d[i + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The logarithmic spiral (angle + log(r) * twist) ensures the arms maintain a constant angle to the radial direction, which is what makes the rotation look like expansion. Your visual system tracks the local edge orientation and interprets the rotation as radial motion. Increase arms for a denser pattern, or negate twist to reverse the apparent direction of flow.

5. Fraser spiral illusion: circles that look like a spiral

The Fraser spiral is one of the most famous optical illusions in perception research. It consists of concentric circles — provably circles, not a spiral — but tilted micro-segments along each circle trick the brain into perceiving a continuous spiral. It was first described by British psychologist James Fraser in 1908.

function draw() {
  ctx.fillStyle = '#fff';
  ctx.fillRect(0, 0, W, H);
  const cx = W / 2, cy = H / 2;
  const numRings = 12;
  const maxR = Math.min(W, H) * 0.45;
  const segments = 360;

  for (let ring = 1; ring <= numRings; ring++) {
    const r = (ring / numRings) * maxR;
    const tiltStrength = 6; // how much each segment tilts
    const segWidth = 8;

    for (let s = 0; s < segments; s++) {
      const a1 = (s / segments) * Math.PI * 2;
      const a2 = ((s + 1) / segments) * Math.PI * 2;

      // Alternating black/white segments with a tilt offset
      const isBlack = (s + ring) % 2 === 0;
      if (!isBlack) continue;

      // Tilt: shift the segment radially based on angle
      const tilt1 = Math.sin(a1 * tiltStrength + ring * 0.5) * 3;
      const tilt2 = Math.sin(a2 * tiltStrength + ring * 0.5) * 3;

      const x1 = cx + (r + tilt1) * Math.cos(a1);
      const y1 = cy + (r + tilt1) * Math.sin(a1);
      const x2 = cx + (r + tilt2) * Math.cos(a2);
      const y2 = cy + (r + tilt2) * Math.sin(a2);
      const x3 = cx + (r + tilt2 + segWidth) * Math.cos(a2);
      const y3 = cy + (r + tilt2 + segWidth) * Math.sin(a2);
      const x4 = cx + (r + tilt1 + segWidth) * Math.cos(a1);
      const y4 = cy + (r + tilt1 + segWidth) * Math.sin(a1);

      ctx.fillStyle = '#000';
      ctx.beginPath();
      ctx.moveTo(x1, y1);
      ctx.lineTo(x2, y2);
      ctx.lineTo(x3, y3);
      ctx.lineTo(x4, y4);
      ctx.fill();
    }
  }
}
draw();

Every ring is a true circle — the radial offset tilt is symmetric and returns to its starting value after each revolution. But the alternating black/white segments are shifted by half a segment width between adjacent rings, creating a diagonal pattern that your brain interprets as spiral connectivity. Cover one ring with your hand and the illusion collapses: you can see that each ring is closed. This illusion reveals that the brain prioritizes local edge direction over global shape.

6. Bridget Riley stripes: vibrating color fields

Bridget Riley is the queen of op art. Her stripe paintings from the 1960s create intense sensations of color vibration, depth, and movement from nothing but parallel stripes with carefully chosen widths and colors. This code version generates Riley-style stripe compositions with animated width modulation.

function draw(time) {
  const t = time * 0.001;
  const numStripes = 40;
  const amplitude = 0.4; // width modulation amount
  const frequency = 3; // modulation waves across the canvas

  // Riley palette: limited, high-contrast
  const colors = ['#000000', '#1a1a6e', '#ffffff', '#1a1a6e'];

  ctx.clearRect(0, 0, W, H);

  let xPos = 0;
  for (let i = 0; i < numStripes * 2 && xPos < W; i++) {
    // Base width varies sinusoidally across canvas
    const progress = xPos / W;
    const modulation = 1 + amplitude * Math.sin(progress * frequency * Math.PI * 2 + t);
    const baseWidth = W / numStripes;
    const stripeW = baseWidth * modulation;

    // Vertical wave: stripe edges undulate
    const waveAmp = 15 * Math.sin(t * 0.7 + i * 0.3);

    ctx.fillStyle = colors[i % colors.length];
    ctx.beginPath();
    ctx.moveTo(xPos, 0);

    // Wavy right edge
    for (let y = 0; y <= H; y += 10) {
      const waveX = xPos + stripeW + Math.sin(y * 0.01 + t + i) * waveAmp;
      ctx.lineTo(waveX, y);
    }

    // Straight left edge (back up)
    for (let y = H; y >= 0; y -= 10) {
      const waveX = xPos + Math.sin(y * 0.01 + t + i - 0.5) * waveAmp;
      ctx.lineTo(waveX, y);
    }

    ctx.fill();
    xPos += stripeW;
  }
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The width modulation creates the illusion of a curved surface — where stripes are compressed, the brain sees the surface receding; where they expand, it sees the surface approaching. The gentle wave on the stripe edges adds a second layer of perceived movement. Riley herself described this effect as “visual energy” — the painting seems to emit its own light and motion. Try changing the color palette: red/green creates maximum chromatic vibration, while black/white creates maximum luminance contrast.

7. Impossible cube: geometry that cannot exist

Impossible figures — like the Penrose triangle and the Necker cube — exploit the brain’s assumption that 2D line drawings represent valid 3D objects. This animated impossible cube continuously rotates, with its edges connected in a way that is locally consistent but globally impossible. Each face looks correct, but the whole cannot exist in 3D space.

function draw(time) {
  ctx.fillStyle = '#000';
  ctx.fillRect(0, 0, W, H);
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 3;
  ctx.lineCap = 'round';

  const t = time * 0.001;
  const cx = W / 2, cy = H / 2;
  const size = 120;

  // Two cubes: front and back, connected impossibly
  const d = size * 0.6; // depth offset
  const angle = t * 0.5;
  const cos = Math.cos(angle), sin = Math.sin(angle);

  // 8 vertices of a cube, with rotation
  function project(x, y, z) {
    // Rotate around Y axis
    const rx = x * cos - z * sin;
    const rz = x * sin + z * cos;
    // Rotate slightly around X axis
    const ry = y * 0.98 - rz * 0.2;
    const rz2 = y * 0.2 + rz * 0.98;
    // Perspective projection
    const scale = 400 / (400 + rz2);
    return [cx + rx * scale, cy + ry * scale, rz2];
  }

  const s = size;
  const verts = [
    [-s, -s, -s], [s, -s, -s], [s, s, -s], [-s, s, -s],
    [-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]
  ];

  const projected = verts.map(v => project(...v));

  // Draw edges with impossible connections
  const edges = [
    [0,1],[1,2],[2,3],[3,0], // front face
    [4,5],[5,6],[6,7],[7,4], // back face
    [0,4],[1,5],[2,6],[3,7]  // connecting edges
  ];

  // Sort faces by depth for painter's algorithm
  const faces = [
    { verts: [0,1,2,3], z: (projected[0][2]+projected[2][2])/2 },
    { verts: [4,5,6,7], z: (projected[4][2]+projected[6][2])/2 },
    { verts: [0,1,5,4], z: (projected[0][2]+projected[5][2])/2 },
    { verts: [2,3,7,6], z: (projected[2][2]+projected[7][2])/2 },
    { verts: [1,2,6,5], z: (projected[1][2]+projected[6][2])/2 },
    { verts: [0,3,7,4], z: (projected[0][2]+projected[7][2])/2 },
  ];
  faces.sort((a, b) => b.z - a.z);

  // Draw filled faces with depth-based shading
  faces.forEach(face => {
    const brightness = 30 + (1 - (face.z + s) / (2 * s)) * 60;
    ctx.fillStyle = `hsl(240, 30%, ${brightness}%)`;
    ctx.beginPath();
    face.verts.forEach((vi, i) => {
      const [px, py] = projected[vi];
      i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
    });
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
  });

  // Impossible overlay: draw one connecting edge OVER a front face
  // This creates the impossible connection
  const impossibleEdge1 = [projected[0], projected[6]];
  const impossibleEdge2 = [projected[1], projected[7]];
  ctx.strokeStyle = '#ff6644';
  ctx.lineWidth = 4;
  ctx.setLineDash([8, 4]);
  [impossibleEdge1, impossibleEdge2].forEach(([a, b]) => {
    ctx.beginPath();
    ctx.moveTo(a[0], a[1]);
    ctx.lineTo(b[0], b[1]);
    ctx.stroke();
  });
  ctx.setLineDash([]);
  ctx.lineWidth = 3;
  ctx.strokeStyle = '#fff';

  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The impossible connections (drawn in orange dashed lines) link vertices that should be at different depths. Locally, each face is a valid projection of a cube face. But the diagonal connections violate the depth ordering — they connect a “near” vertex to a “far” vertex through space that is already occupied by a face. As the cube rotates, the impossibility shifts between different faces, creating a continuously paradoxical object. This is the same principle behind M.C. Escher’s impossible staircases and Penrose’s impossible triangle.

8. Generative op art composition: combining techniques

A full-canvas generative op art piece that combines multiple illusion techniques into a single composition: radial warping, moiré interference, spiral motion, and stripe modulation. Click to regenerate with a new random seed.

let seed = Date.now();
function random() {
  seed = (seed * 16807 + 0) % 2147483647;
  return (seed - 1) / 2147483646;
}

function generate() {
  seed = Date.now();
  // Random parameters
  const numLayers = 3 + (random() * 3) | 0;
  const palette = [
    ['#000', '#fff'],
    ['#000', '#1a1a6e', '#fff'],
    ['#000', '#444', '#fff', '#444'],
    ['#1a0a2e', '#fff', '#6a0dad'],
  ][(random() * 4) | 0];

  const layers = [];
  for (let i = 0; i < numLayers; i++) {
    layers.push({
      type: ['rings', 'spiral', 'grid', 'radial'][(random() * 4) | 0],
      freq: 5 + random() * 25,
      phase: random() * Math.PI * 2,
      cx: W * (0.2 + random() * 0.6),
      cy: H * (0.2 + random() * 0.6),
      rotation: random() * Math.PI * 2,
    });
  }

  function renderFrame(time) {
    const img = ctx.createImageData(W, H);
    const d = img.data;
    const t = time * 0.001;

    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        let sum = 0;

        for (const layer of layers) {
          const dx = x - layer.cx, dy = y - layer.cy;
          const cos = Math.cos(layer.rotation);
          const sin = Math.sin(layer.rotation);
          const rx = dx * cos - dy * sin;
          const ry = dx * sin + dy * cos;
          const r = Math.sqrt(rx * rx + ry * ry);
          const angle = Math.atan2(ry, rx);

          let val = 0;
          switch (layer.type) {
            case 'rings':
              val = Math.sin(r * layer.freq / W * Math.PI * 2 - t + layer.phase);
              break;
            case 'spiral':
              val = Math.sin(angle * 4 + Math.log(r + 1) * layer.freq / 10 - t * 0.5 + layer.phase);
              break;
            case 'grid':
              val = Math.sin(rx * layer.freq / W * Math.PI * 2 + layer.phase) *
                    Math.sin(ry * layer.freq / W * Math.PI * 2 + layer.phase + t * 0.3);
              break;
            case 'radial':
              val = Math.sin(angle * layer.freq / 3 + r * 0.02 - t + layer.phase);
              break;
          }
          sum += val;
        }

        // Normalize and threshold
        const normalized = sum / numLayers;
        const colIdx = Math.abs(normalized) < 0.3 ? 0 :
                       normalized > 0 ? Math.min(palette.length - 1, 1) :
                       Math.min(palette.length - 1, 2);
        const hex = palette[colIdx] || palette[0];
        const r = parseInt(hex.slice(1, 3), 16) || 0;
        const g = parseInt(hex.slice(3, 5), 16) || 0;
        const b = parseInt(hex.slice(5, 7), 16) || 0;

        const i = (y * W + x) * 4;
        d[i] = r; d[i + 1] = g; d[i + 2] = b; d[i + 3] = 255;
      }
    }
    ctx.putImageData(img, 0, 0);
    requestAnimationFrame(renderFrame);
  }
  requestAnimationFrame(renderFrame);
}

c.addEventListener('click', generate);
c.style.cursor = 'pointer';
generate();

Going further with op art

The eight examples above cover the fundamental techniques of op art with code, but there are many more directions to explore:

  • Color interaction — Josef Albers showed that identical colors look completely different depending on their surroundings. Build an interactive demo where the same color patch sits on different backgrounds, revealing how context changes perception.
  • Afterimage effects — Display a high-contrast pattern for 30 seconds, then switch to a blank screen. The viewer sees the complementary colors floating in their vision. This is a physiological illusion, not a cognitive one — the retinal cells actually fatigue.
  • Kinetic op art — Overlay two patterns and animate one at a slow, constant speed. The moiré interference moves at a much faster apparent speed — a mechanical amplification of motion. Physical op art sculptures use this principle with rotating discs.
  • Stereograms — Random-dot stereograms (Magic Eye images) hide 3D shapes in seemingly random noise. The algorithm is surprisingly simple: shift columns of pixels by a depth-dependent amount.
  • WebGL shaders — All the pixel-by-pixel patterns above are perfect candidates for fragment shaders, where they run at full resolution and 60fps even on 4K displays.

Op art reminds us that seeing is not a passive process — it is an active construction by the brain, full of assumptions and shortcuts that can be exploited. Every optical illusion is a window into the architecture of human perception. When you code an op art piece, you are not just making a pattern: you are writing a program that runs on the viewer’s visual cortex.

Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For more pattern techniques, try the geometric art guide, the tessellation art guide, the math art guide, or the mandala art guide.

Related articles