All articles
22 min read

Optical Illusion Art: How to Create Mind-Bending Visual Illusions With Code

optical illusion artoptical illusionsop artcreative codingJavaScriptgenerative artvisual perception

Your eyes lie to you. Constantly. Optical illusions exploit the shortcuts your visual system uses to make sense of the world — and when you understand those shortcuts, you can create art that makes people question what they're seeing. Lines that aren't parallel look parallel. Identical colors look different. Static images appear to move. Shapes that can't exist in three dimensions sit right there on screen, perfectly rendered.

Optical illusion art has a rich history: from M.C. Escher's impossible architectures to Bridget Riley's dizzying op art canvases to Akiyoshi Kitaoka's rotating snakes. What these artists discovered intuitively, neuroscience now explains — and code lets us build interactively. In this guide, we'll create 8 optical illusions from scratch using JavaScript and the HTML Canvas API. No libraries. No frameworks. Just perception and pixels.

1. Checker Shadow illusion — identical colors that look different

This is arguably the most famous optical illusion in vision science. Created by Edward Adelson at MIT in 1995, it demonstrates that our brains don't perceive absolute brightness — they perceive brightness relative to context. Two squares on a checkerboard that are physically identical in color appear dramatically different because one sits in shadow and one doesn't. Your visual cortex "corrects" for the shadow automatically.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const size = 50, cols = 8, rows = 8;
const ox = (500 - cols * size) / 2, oy = (500 - rows * size) / 2;

// Draw checkerboard
for (let r = 0; r < rows; r++) {
  for (let c = 0; c < cols; c++) {
    const light = (r + c) % 2 === 0;
    ctx.fillStyle = light ? '#b8b8b8' : '#585858';
    ctx.fillRect(ox + c * size, oy + r * size, size, size);
  }
}

// Draw shadow — a diagonal gradient overlay
ctx.save();
ctx.beginPath();
ctx.moveTo(ox + size * 2, oy);
ctx.lineTo(ox + cols * size, oy + size * 4);
ctx.lineTo(ox + cols * size, oy + rows * size);
ctx.lineTo(ox, oy + rows * size);
ctx.lineTo(ox, oy + size * 2);
ctx.closePath();
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fill();
ctx.restore();

// Mark the two identical squares
const sqA = { r: 3, c: 3 }; // dark square outside shadow
const sqB = { r: 5, c: 4 }; // light square inside shadow

function drawMarker(row, col, label) {
  const x = ox + col * size + size / 2;
  const y = oy + row * size + size / 2;
  ctx.strokeStyle = '#ff3366';
  ctx.lineWidth = 3;
  ctx.strokeRect(ox + col * size + 3, oy + row * size + 3, size - 6, size - 6);
  ctx.fillStyle = '#ff3366';
  ctx.font = 'bold 18px monospace';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(label, x, y);
}

drawMarker(sqA.r, sqA.c, 'A');
drawMarker(sqB.r, sqB.c, 'B');

// Info text
ctx.fillStyle = '#fff';
ctx.font = '14px system-ui';
ctx.textAlign = 'center';
ctx.fillText('Squares A and B are the exact same color.', 250, oy + rows * size + 30);
ctx.fillText('Click to reveal proof →', 250, oy + rows * size + 50);

// Click to prove — draw connecting bridge of same color
let revealed = false;
canvas.onclick = () => {
  if (revealed) return;
  revealed = true;
  const ax = ox + sqA.c * size + size / 2;
  const ay = oy + sqA.r * size + size / 2;
  const bx = ox + sqB.c * size + size / 2;
  const by = oy + sqB.r * size + size / 2;
  ctx.strokeStyle = '#888888';
  ctx.lineWidth = size * 0.6;
  ctx.beginPath();
  ctx.moveTo(ax, ay);
  ctx.lineTo(bx, by);
  ctx.stroke();
  ctx.fillStyle = '#fff';
  ctx.font = '14px system-ui';
  ctx.fillText('Same color bridge connects A and B', 250, oy + rows * size + 70);
};

The illusion works because your visual system uses lateral inhibition — neurons suppress their neighbors to enhance edges — combined with constancy, the automatic compensation for lighting conditions. Your brain "knows" the shadowed area is darker, so it boosts perceived brightness of anything in the shadow zone. The result: square B (in shadow, actually #888) looks much lighter than square A (outside shadow, also #888).

2. Impossible triangle — Penrose geometry that can't exist

The Penrose triangle (or "tribar") was first published by mathematician Roger Penrose in 1958, though Swedish artist Oscar Reutersvärd created one in 1934. Each corner looks perfectly normal — the impossibility only emerges when you try to follow the whole shape. M.C. Escher used this principle extensively in works like Waterfall and Ascending and Descending.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const cx = 250, cy = 250;
const outer = 180, inner = 80;
const a3 = Math.PI * 2 / 3;

// Three colors for the three faces
const colors = ['#4488ff', '#44cc88', '#ff6644'];
const darks  = ['#2266cc', '#229966', '#cc4422'];

function drawPenroseTriangle(t) {
  ctx.clearRect(0, 0, 500, 500);
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 500, 500);

  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate(t * 0.2);

  // Calculate the six vertices (outer and inner triangle)
  const outerPts = [], innerPts = [];
  for (let i = 0; i < 3; i++) {
    const angle = -Math.PI / 2 + i * a3;
    outerPts.push({ x: Math.cos(angle) * outer, y: Math.sin(angle) * outer });
    innerPts.push({ x: Math.cos(angle) * inner, y: Math.sin(angle) * inner });
  }

  // Draw each bar of the triangle — the trick is occlusion order
  for (let i = 0; i < 3; i++) {
    const next = (i + 1) % 3;
    const prev = (i + 2) % 3;

    // Outer face
    ctx.beginPath();
    ctx.moveTo(outerPts[i].x, outerPts[i].y);
    ctx.lineTo(outerPts[next].x, outerPts[next].y);
    ctx.lineTo(innerPts[next].x, innerPts[next].y);
    ctx.lineTo(innerPts[i].x, innerPts[i].y);
    ctx.closePath();
    ctx.fillStyle = colors[i];
    ctx.fill();
    ctx.strokeStyle = '#111';
    ctx.lineWidth = 2;
    ctx.stroke();

    // Inner bevel face
    ctx.beginPath();
    ctx.moveTo(innerPts[i].x, innerPts[i].y);
    ctx.lineTo(innerPts[next].x, innerPts[next].y);
    const midX = (innerPts[i].x + innerPts[next].x) / 2 * 0.6;
    const midY = (innerPts[i].y + innerPts[next].y) / 2 * 0.6;
    ctx.lineTo(midX, midY);
    ctx.closePath();
    ctx.fillStyle = darks[i];
    ctx.fill();
    ctx.stroke();
  }

  // Redraw first corner's overlap to create the impossible connection
  const i = 0, next = 1;
  ctx.beginPath();
  ctx.moveTo(outerPts[i].x, outerPts[i].y);
  const dx = outerPts[next].x - outerPts[i].x;
  const dy = outerPts[next].y - outerPts[i].y;
  ctx.lineTo(outerPts[i].x + dx * 0.25, outerPts[i].y + dy * 0.25);
  ctx.lineTo(innerPts[i].x + dx * 0.25, innerPts[i].y + dy * 0.25);
  ctx.lineTo(innerPts[i].x, innerPts[i].y);
  ctx.closePath();
  ctx.fillStyle = colors[0];
  ctx.fill();
  ctx.strokeStyle = '#111';
  ctx.lineWidth = 2;
  ctx.stroke();

  ctx.restore();
}

let time = 0;
function animate() {
  drawPenroseTriangle(time);
  time += 0.005;
  requestAnimationFrame(animate);
}
animate();

The impossible triangle works because our visual system processes local geometry independently before integrating globally. Each corner's T-junction and depth cues are locally consistent — it's only the global topology that's impossible. This is why the illusion persists even when you know it's impossible: local processing happens pre-attentively, before conscious analysis.

3. Rotating snakes — static images that appear to move

Akiyoshi Kitaoka's "Rotating Snakes" (2003) is the most powerful peripheral drift illusion ever created. The pattern is completely static, yet it appears to rotate — especially in peripheral vision. The effect relies on specific luminance sequences: black → dark gray → white → light gray creates perceived motion in one direction. Reversing the sequence reverses the motion.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#888';
ctx.fillRect(0, 0, 500, 500);

// The luminance sequence that creates perceived motion
// black → dark → white → light = clockwise rotation
const sequence = ['#000000', '#444444', '#ffffff', '#bbbbbb'];
const ringCount = 4;
const sectorsPerRing = 24;

function drawRotatingSnakeDisc(cx, cy, radius) {
  for (let ring = ringCount - 1; ring >= 0; ring--) {
    const r1 = radius * (ring + 1) / ringCount;
    const r0 = radius * ring / ringCount;

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

      // Offset the sequence per ring to create the spiral
      const colorIdx = (s + ring) % 4;
      ctx.fillStyle = sequence[colorIdx];

      ctx.beginPath();
      ctx.arc(cx, cy, r1, a0, a1);
      ctx.arc(cx, cy, r0, a1, a0, true);
      ctx.closePath();
      ctx.fill();
    }
  }

  // Center dot
  ctx.beginPath();
  ctx.arc(cx, cy, 4, 0, Math.PI * 2);
  ctx.fillStyle = '#ffcc00';
  ctx.fill();
}

// Draw a grid of discs — peripheral vision makes them "rotate"
const positions = [
  [125, 125], [375, 125], [250, 250], [125, 375], [375, 375]
];

positions.forEach(([x, y], i) => {
  drawRotatingSnakeDisc(x, y, 90);
});

// Instructions
ctx.fillStyle = '#fff';
ctx.font = '13px system-ui';
ctx.textAlign = 'center';
ctx.fillText("Don't stare at one disc — look around the image.", 250, 490);
ctx.fillText('The discs appear to rotate in peripheral vision.', 250, 475);

Why does a static image appear to move? The leading theory (Conway et al., 2005) involves the temporal response of neurons. High-contrast edges (black-white) are processed faster than low-contrast edges (dark gray - light gray). The luminance staircase creates a temporal gradient across each sector — your visual system interprets this timing difference as motion, particularly in peripheral vision where temporal resolution is lower but motion sensitivity is higher.

4. Müller-Lyer arrows — equal lines that look unequal

The Müller-Lyer illusion (1889) is one of the most studied illusions in psychology. Two lines of identical length appear different because of the arrowheads at their ends: outward-pointing fins make the line look longer, inward-pointing fins make it look shorter. The strength of the effect is remarkable — most people perceive a 20-30% difference in length where there is none.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

let separation = 0; // animated reveal

function draw() {
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 500, 400);

  const lineLen = 200;
  const finLen = 40;
  const finAngle = Math.PI / 6; // 30 degrees
  const cx = 250;

  // Line A — outward fins (appears longer)
  const ay = 140;
  ctx.strokeStyle = '#ff6644';
  ctx.lineWidth = 4;
  ctx.lineCap = 'round';

  ctx.beginPath();
  ctx.moveTo(cx - lineLen / 2, ay);
  ctx.lineTo(cx + lineLen / 2, ay);
  ctx.stroke();

  // Left fin — outward
  ctx.beginPath();
  ctx.moveTo(cx - lineLen / 2, ay);
  ctx.lineTo(cx - lineLen / 2 - Math.cos(finAngle) * finLen, ay - Math.sin(finAngle) * finLen);
  ctx.moveTo(cx - lineLen / 2, ay);
  ctx.lineTo(cx - lineLen / 2 - Math.cos(finAngle) * finLen, ay + Math.sin(finAngle) * finLen);
  ctx.stroke();

  // Right fin — outward
  ctx.beginPath();
  ctx.moveTo(cx + lineLen / 2, ay);
  ctx.lineTo(cx + lineLen / 2 + Math.cos(finAngle) * finLen, ay - Math.sin(finAngle) * finLen);
  ctx.moveTo(cx + lineLen / 2, ay);
  ctx.lineTo(cx + lineLen / 2 + Math.cos(finAngle) * finLen, ay + Math.sin(finAngle) * finLen);
  ctx.stroke();

  // Line B — inward fins (appears shorter)
  const by = 260;
  ctx.strokeStyle = '#4488ff';

  ctx.beginPath();
  ctx.moveTo(cx - lineLen / 2, by);
  ctx.lineTo(cx + lineLen / 2, by);
  ctx.stroke();

  // Left fin — inward
  ctx.beginPath();
  ctx.moveTo(cx - lineLen / 2, by);
  ctx.lineTo(cx - lineLen / 2 + Math.cos(finAngle) * finLen, by - Math.sin(finAngle) * finLen);
  ctx.moveTo(cx - lineLen / 2, by);
  ctx.lineTo(cx - lineLen / 2 + Math.cos(finAngle) * finLen, by + Math.sin(finAngle) * finLen);
  ctx.stroke();

  // Right fin — inward
  ctx.beginPath();
  ctx.moveTo(cx + lineLen / 2, by);
  ctx.lineTo(cx + lineLen / 2 - Math.cos(finAngle) * finLen, by - Math.sin(finAngle) * finLen);
  ctx.moveTo(cx + lineLen / 2, by);
  ctx.lineTo(cx + lineLen / 2 - Math.cos(finAngle) * finLen, by + Math.sin(finAngle) * finLen);
  ctx.stroke();

  // Animated measurement lines
  if (separation > 0) {
    ctx.setLineDash([4, 4]);
    ctx.strokeStyle = '#888';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(cx - lineLen / 2, ay + 15);
    ctx.lineTo(cx - lineLen / 2, by - 15);
    ctx.moveTo(cx + lineLen / 2, ay + 15);
    ctx.lineTo(cx + lineLen / 2, by - 15);
    ctx.stroke();
    ctx.setLineDash([]);
  }

  // Labels
  ctx.fillStyle = '#ff6644';
  ctx.font = '16px system-ui';
  ctx.textAlign = 'center';
  ctx.fillText('Line A: 200px', cx, ay - 30);

  ctx.fillStyle = '#4488ff';
  ctx.fillText('Line B: 200px', cx, by + 50);

  ctx.fillStyle = '#888';
  ctx.font = '13px system-ui';
  ctx.fillText('Both lines are exactly the same length.', cx, 370);
  ctx.fillText('Click to toggle measurement guides.', cx, 388);
}

let showGuides = false;
canvas.onclick = () => {
  showGuides = !showGuides;
  separation = showGuides ? 1 : 0;
  draw();
};

draw();

The dominant explanation is the depth processing hypothesis: outward-pointing fins resemble the inside corner of a room (concave edge), which your brain interprets as farther away. Since the retinal image is the same size but perceived as farther, the brain scales it up — the same mechanism behind the moon illusion. The inward fins suggest a convex edge (like a building corner), perceived as closer, so the brain scales it down.

5. Kanizsa triangle — shapes that aren't there

Italian psychologist Gaetano Kanizsa described this illusion in 1955. Three Pac-Man shapes and three line pairs create the vivid perception of a bright white triangle — complete with sharp edges and apparent brightness — that doesn't actually exist. Your visual system constructs the triangle from the alignment cues. The illusory contours are so strong that most people will swear the central triangle is whiter than the background, even though it's the same color.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

let mouthAngle = Math.PI / 3; // animate the pac-man mouths

function draw(t) {
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, 500, 500);

  const cx = 250, cy = 240;
  const triR = 140; // distance from center to vertices
  const discR = 45; // pac-man radius
  const anim = Math.sin(t * 0.8) * 0.15; // subtle animation

  // Calculate triangle vertices
  const verts = [];
  for (let i = 0; i < 3; i++) {
    const a = -Math.PI / 2 + i * (Math.PI * 2 / 3);
    verts.push({
      x: cx + Math.cos(a) * triR,
      y: cy + Math.sin(a) * triR
    });
  }

  // Draw the inverted (outline) triangle behind
  ctx.strokeStyle = '#4444ff';
  ctx.lineWidth = 3;
  const invVerts = [];
  for (let i = 0; i < 3; i++) {
    const a = Math.PI / 6 + i * (Math.PI * 2 / 3);
    invVerts.push({
      x: cx + Math.cos(a) * (triR * 0.75),
      y: cy + Math.sin(a) * (triR * 0.75)
    });
  }
  ctx.beginPath();
  ctx.moveTo(invVerts[0].x, invVerts[0].y);
  for (let i = 1; i < 3; i++) ctx.lineTo(invVerts[i].x, invVerts[i].y);
  ctx.closePath();
  ctx.stroke();

  // Draw pac-man inducers — three circles with wedges cut out
  const currentMouth = mouthAngle + anim;
  ctx.fillStyle = '#4444ff';

  for (let i = 0; i < 3; i++) {
    const v = verts[i];
    // Angle pointing toward center
    const toCenter = Math.atan2(cy - v.y, cx - v.x);

    ctx.beginPath();
    ctx.moveTo(v.x, v.y);
    ctx.arc(v.x, v.y, discR, toCenter + currentMouth / 2, toCenter - currentMouth / 2);
    ctx.closePath();
    ctx.fill();
  }

  // Label
  ctx.fillStyle = '#999';
  ctx.font = '14px system-ui';
  ctx.textAlign = 'center';
  ctx.fillText('Do you see the white triangle? It doesn\'t exist.', 250, 440);
  ctx.fillText('Your brain constructs it from alignment cues.', 250, 460);
}

let time = 0;
function animate() {
  draw(time);
  time += 0.016;
  requestAnimationFrame(animate);
}
animate();

The Kanizsa triangle demonstrates amodal completion — your visual system's tendency to "fill in" occluded shapes. The V2 area of your visual cortex contains neurons that respond to illusory contours as strongly as to real ones (von der Heydt et al., 1984). The brightness enhancement of the illusory triangle is caused by contrast induction at the perceived edges. Your brain treats the Pac-Man alignment as evidence that a white triangle is occluding three circles and a second triangle — and renders the scene accordingly.

6. Hermann grid — ghostly dots at every intersection

Ludimar Hermann first described this in 1870. Look at a white grid on a black background and you'll see faint gray dots flickering at every intersection — except the one you're looking directly at. Move your eyes and the ghosts dance away. This illusion is especially effective with a large grid and high contrast.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

function drawHermannGrid(cellSize, lineWidth, bg, fg) {
  ctx.fillStyle = bg;
  ctx.fillRect(0, 0, 500, 500);

  const cols = Math.floor(500 / cellSize);
  const rows = Math.floor(500 / cellSize);

  ctx.fillStyle = fg;

  // Draw vertical lines
  for (let c = 0; c <= cols; c++) {
    const x = c * cellSize - lineWidth / 2;
    ctx.fillRect(x, 0, lineWidth, 500);
  }

  // Draw horizontal lines
  for (let r = 0; r <= rows; r++) {
    const y = r * cellSize - lineWidth / 2;
    ctx.fillRect(0, y, 500, lineWidth);
  }
}

let variant = 0;
const variants = [
  { cellSize: 60, lineWidth: 12, bg: '#000', fg: '#fff', label: 'Classic Hermann Grid' },
  { cellSize: 60, lineWidth: 12, bg: '#fff', fg: '#000', label: 'Inverted — dark dots appear' },
  { cellSize: 45, lineWidth: 8, bg: '#001133', fg: '#00ccff', label: 'Neon variant' },
  { cellSize: 80, lineWidth: 16, bg: '#222', fg: '#aaa', label: 'Low contrast — weaker illusion' },
];

function draw() {
  const v = variants[variant];
  drawHermannGrid(v.cellSize, v.lineWidth, v.bg, v.fg);

  // Label
  ctx.fillStyle = '#ff3366';
  ctx.font = 'bold 14px system-ui';
  ctx.textAlign = 'center';
  ctx.fillText(v.label, 250, 485);
  ctx.font = '12px system-ui';
  ctx.fillText('Click to cycle variants. Gray dots appear at intersections.', 250, 470);
}

canvas.onclick = () => {
  variant = (variant + 1) % variants.length;
  draw();
};

draw();

The traditional explanation involved lateral inhibition in retinal ganglion cells: at intersections, the receptive field surround receives more light (from four white bars) than along a single bar (two bars), producing stronger inhibition and a darker perceived spot. However, Schiller and Carvey (2005) showed this account doesn't fully work — a curved grid breaks the illusion completely. The current consensus is that the effect involves S1 simple cells in primary visual cortex that respond to oriented edges. At intersections, no single orientation dominates, creating an ambiguous signal that's resolved as lower brightness.

7. Café Wall illusion — straight lines that look crooked

Named after a tiled wall at a café in Bristol (discovered by Richard Gregory in 1979), this illusion makes perfectly horizontal lines appear to slope. Alternating rows of black and white tiles with a half-tile offset — separated by a thin gray mortar line — create the perception that the rows wedge apart. The mortar color is critical: pure black or white mortar eliminates the illusion.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

let mortarWidth = 3;
let mortarColor = '#808080'; // gray mortar = strong illusion
let animated = true;
let offset = 0;

function drawCafeWall(t) {
  ctx.fillStyle = '#333';
  ctx.fillRect(0, 0, 500, 500);

  const tileW = 50;
  const tileH = 40;
  const rows = Math.ceil(500 / (tileH + mortarWidth));
  const cols = Math.ceil(500 / tileW) + 2;

  for (let r = 0; r < rows; r++) {
    const y = r * (tileH + mortarWidth);

    // Draw mortar line
    ctx.fillStyle = mortarColor;
    ctx.fillRect(0, y + tileH, 500, mortarWidth);

    // Alternating row offset (half-tile shift per row)
    const rowOffset = (r % 2) * (tileW / 2);
    const animOffset = animated ? Math.sin(t + r * 0.3) * 5 : 0;

    for (let c = -1; c < cols; c++) {
      const x = c * tileW + rowOffset + animOffset;
      const isBlack = c % 2 === 0;
      ctx.fillStyle = isBlack ? '#111' : '#eee';
      ctx.fillRect(x, y, tileW, tileH);
    }
  }

  // Draw perfectly straight reference line
  ctx.strokeStyle = '#ff3366';
  ctx.lineWidth = 1;
  ctx.setLineDash([4, 4]);
  for (let r = 1; r < rows; r++) {
    const y = r * (tileH + mortarWidth) + tileH / 2;
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(500, y);
    ctx.stroke();
  }
  ctx.setLineDash([]);

  // Label
  ctx.fillStyle = '#ff3366';
  ctx.font = '13px system-ui';
  ctx.textAlign = 'center';
  ctx.fillText('Red dashed lines are perfectly straight and parallel.', 250, 488);
  ctx.fillText('Click to toggle animation.', 250, 472);
}

canvas.onclick = () => { animated = !animated; };

function animate() {
  offset += 0.02;
  drawCafeWall(offset);
  requestAnimationFrame(animate);
}
animate();

The Café Wall illusion is caused by border locking — a mechanism where the thin gray mortar line creates ambiguous borders. Where a white tile meets the gray mortar, the border appears to shift slightly toward the white side. Where a black tile meets the gray mortar, the border shifts toward the black side. Because tiles in adjacent rows are offset by half a tile width, these micro-shifts accumulate systematically, making each mortar line appear to tilt. The mortar must be intermediate brightness (gray) — if it matches either tile, the border is unambiguous and the illusion vanishes.

8. Generative op art — interactive Bridget Riley tribute

Bridget Riley is the master of optical art. Her paintings from the 1960s — fields of undulating black and white lines, warped grids, and vibrating color patches — cause genuine perceptual effects: apparent motion, color ghosting, eye strain, even nausea. Let's build an interactive op art generator that combines several illusory effects: peripheral motion from line density variation, moiré from overlapping curves, and figure-ground instability from ambiguous edges.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

let mx = 250, my = 250;
canvas.onmousemove = e => {
  const r = canvas.getBoundingClientRect();
  mx = e.clientX - r.left;
  my = e.clientY - r.top;
};

function draw(t) {
  ctx.fillStyle = '#fff';
  ctx.fillRect(0, 0, 500, 500);

  const lines = 80;
  ctx.strokeStyle = '#000';
  ctx.lineWidth = 2.5;

  for (let i = 0; i < lines; i++) {
    const baseY = (i / lines) * 500;

    ctx.beginPath();
    for (let x = 0; x <= 500; x += 3) {
      // Distance from mouse — creates the "bulge"
      const dx = x - mx;
      const dy = baseY - my;
      const dist = Math.sqrt(dx * dx + dy * dy);

      // Warp amount — stronger near mouse
      const warp = Math.exp(-dist * dist / 15000) * 40;

      // Sine wave distortion + mouse warping
      const wave = Math.sin(x * 0.02 + t + i * 0.1) * warp;
      const bulge = Math.exp(-dist * dist / 8000) * 30 * Math.sin(t * 2 + dist * 0.02);

      const y = baseY + wave + bulge;

      if (x === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  // Add concentric circles for moiré interference
  ctx.strokeStyle = 'rgba(0,0,0,0.15)';
  ctx.lineWidth = 1.5;
  const maxR = 350;
  for (let r = 10; r < maxR; r += 8) {
    ctx.beginPath();
    ctx.arc(mx, my, r, 0, Math.PI * 2);
    ctx.stroke();
  }

  // Label
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  ctx.font = '12px system-ui';
  ctx.textAlign = 'center';
  ctx.fillText('Move mouse to warp the field — watch the edges vibrate', 250, 490);
}

let time = 0;
function animate() {
  draw(time);
  time += 0.015;
  requestAnimationFrame(animate);
}
animate();

This generator combines multiple perceptual effects. The peripheral drift comes from the varying line density — dense regions stimulate motion-detecting neurons differently than sparse regions. The moiré between the concentric circles and the wavy lines creates phantom patterns that shift when you move your eyes. The figure-ground instability at the bulge center — where lines are most compressed — makes the surface appear to flip between convex and concave. Riley understood these effects intuitively; we can now parameterize them precisely.

The neuroscience of why illusions work

All optical illusions exploit the same fundamental fact: your visual system is not a camera. It's a prediction engine. The brain receives 10 million bits per second from the retina but can only consciously process ~40. Everything else is filled in by models, assumptions, and shortcuts that evolution optimized for survival, not accuracy.

Key mechanisms that illusions exploit:

  • Lateral inhibition: Neurons suppress their neighbors to sharpen edges. This creates brightness effects (Mach bands, Hermann grid) where no physical brightness change exists.
  • Constancy mechanisms: The brain compensates for lighting, size, color temperature. The Checker Shadow illusion exploits lightness constancy. The Müller-Lyer exploits size constancy.
  • Gestalt grouping: The visual system groups elements by proximity, similarity, continuity, and closure. Kanizsa triangles exploit closure. Op art exploits proximity and continuity.
  • Temporal processing: Different contrasts are processed at different speeds. Rotating Snakes exploits this differential timing to create illusory motion from a static image.
  • Predictive coding: The brain sends predictions downward and only processes the error signal. When the error signal is ambiguous (as at Café Wall mortar lines), the prediction wins — and the prediction can be wrong.

History of optical illusion art

Optical illusion art spans millennia:

  • Ancient Rome: Pompeii murals used trompe l'oeil — architectural paintings that made flat walls appear to contain windows, columns, and gardens.
  • Renaissance: Andrea Mantegna's Camera degli Sposi (1474) and Andrea Pozzo's ceiling of Sant'Ignazio (1694) used extreme anamorphic perspective to create the illusion of infinite space.
  • 19th century: Hermann, Müller-Lyer, Zöllner, and others formalized the scientific study of visual illusions, cataloging dozens of distinct effects.
  • 1960s Op Art: Bridget Riley, Victor Vasarely, and others created paintings that were pure optical experience — no representation, just perception. Riley's Fall (1963) literally made gallery visitors nauseous from its undulating curves.
  • M.C. Escher: Combined impossible geometry with tessellation to create images that loop endlessly: waterfalls that flow uphill, staircases that ascend in circles, hands that draw themselves.
  • 21st century: Akiyoshi Kitaoka, Beau Lotto, and others use computational methods to design illusions of unprecedented strength. Digital tools allow precise control of every variable.

Going further

  • Explore geometric art for related visual techniques: Islamic patterns, Penrose tilings, kaleidoscopes, and symmetry operations that underpin many optical illusions
  • Learn about moiré patterns — the interference between overlapping grids is one of the most powerful sources of optical illusion effects
  • Try mathematical art for pattern families like rose curves, superformula, and strange attractors that create complex visual fields
  • Combine optical illusions with color theory — simultaneous contrast, color constancy, and chromatic adaptation create a whole separate family of color illusions
  • Add SVG animation to your illusions — smooth morphing between possible and impossible shapes creates powerful perceptual effects
  • Use Perlin noise to subtly warp grid-based illusions — organic distortion makes the illusions feel natural rather than mechanical
  • Build a tessellation that transitions between two different illusions — each tile is locally consistent but globally impossible
  • On Lumitree, several micro-worlds use optical illusion principles — overlapping patterns, impossible geometry, and perception-bending color fields that respond to viewer interaction

Related articles