All articles
19 min read

Tessellation Art: How to Create Mesmerizing Tiling Patterns With Code

tessellationtiling patternscreative codingJavaScriptgenerative art

Tessellation is the art of covering a surface with shapes that fit together perfectly — no gaps, no overlaps. From ancient Roman mosaics and Islamic geometric art to M.C. Escher's metamorphic lizards and the recent discovery of the aperiodic monotile, tessellations sit at the intersection of mathematics, art, and wonder. They answer one of geometry's most fundamental questions: which shapes can tile the plane?

Code is the perfect medium for tessellation art because the rules are precise and mathematical. Regular polygons, symmetry operations, edge-matching constraints, and recursive subdivision can all be expressed as algorithms. A few dozen lines of code can generate patterns that would take an artisan weeks to draw by hand — and once you have the algorithm, you can animate it, distort it, and explore infinite variations.

This guide builds 8 tessellation techniques from scratch in JavaScript and Canvas. We start with simple regular tilings and progress to Penrose aperiodic tilings, hyperbolic geometry, and animated metamorphosis. Every example runs in your browser with no dependencies.

The mathematics of tiling

A tessellation (or tiling) must satisfy one rule: the shapes must fill the plane completely with no gaps and no overlaps. This simple constraint leads to deep mathematics:

  • Regular tilings: only three regular polygons tile the plane alone — equilateral triangles, squares, and regular hexagons. Their interior angles must divide 360° evenly at each vertex
  • Semi-regular tilings: combining two or more regular polygons gives 8 Archimedean tilings (like the bathroom-floor pattern of octagons and squares)
  • Escher-style tilings: any edge modification that preserves the tiling property creates figurative tiles — lizards, birds, fish — from simple geometric starting shapes
  • Aperiodic tilings: Penrose tilings use just two shapes to fill the plane but never repeat. They have five-fold symmetry, which is forbidden in periodic crystals
  • Hyperbolic tilings: in curved space, regular heptagons and other shapes that can't tile flat space become valid tessellations

Example 1: The three regular tilings

Only three regular polygons tile the plane by themselves. This example draws all three side by side: triangles (6 meet at each vertex, 6×60°=360°), squares (4×90°=360°), and hexagons (3×120°=360°). Click to cycle through them.

var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var mode = 0;

c.addEventListener('click', function() { mode = (mode + 1) % 3; });

function polygon(cx, cy, r, sides, rot) {
  ctx.beginPath();
  for (var i = 0; i <= sides; i++) {
    var a = (i / sides) * Math.PI * 2 + rot;
    var px = cx + Math.cos(a) * r;
    var py = cy + Math.sin(a) * r;
    if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.closePath();
}

function drawTriangles() {
  var s = 40;
  var h = s * Math.sqrt(3) / 2;
  for (var row = -1; row < c.height / h + 1; row++) {
    for (var col = -1; col < c.width / (s / 2) + 1; col++) {
      var up = (row + col) % 2 === 0;
      var cx = col * s / 2;
      var cy = row * h + (up ? h / 3 : h * 2 / 3);
      var rot = up ? -Math.PI / 2 : Math.PI / 2;
      polygon(cx, cy, s / Math.sqrt(3), 3, rot);
      var hue = ((row * 7 + col * 13) % 12) * 30;
      ctx.fillStyle = 'hsl(' + hue + ',60%,75%)';
      ctx.fill();
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 1.5;
      ctx.stroke();
    }
  }
}

function drawSquares() {
  var s = 45;
  for (var row = 0; row < c.height / s + 1; row++) {
    for (var col = 0; col < c.width / s + 1; col++) {
      var x = col * s, y = row * s;
      var hue = ((row * 5 + col * 8) % 10) * 36;
      ctx.fillStyle = 'hsl(' + hue + ',55%,72%)';
      ctx.fillRect(x, y, s, s);
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 1.5;
      ctx.strokeRect(x, y, s, s);
    }
  }
}

function drawHexagons() {
  var r = 28;
  var w = r * 2;
  var h = r * Math.sqrt(3);
  for (var row = -1; row < c.height / h + 2; row++) {
    for (var col = -1; col < c.width / (w * 0.75) + 2; col++) {
      var cx = col * w * 0.75;
      var cy = row * h + (col % 2 === 0 ? 0 : h / 2);
      polygon(cx, cy, r, 6, 0);
      var hue = ((row * 3 + col * 7) % 8) * 45;
      ctx.fillStyle = 'hsl(' + hue + ',50%,70%)';
      ctx.fill();
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 1.5;
      ctx.stroke();
    }
  }
}

function draw() {
  ctx.fillStyle = '#1a1a2e';
  ctx.fillRect(0, 0, c.width, c.height);
  if (mode === 0) drawTriangles();
  else if (mode === 1) drawSquares();
  else drawHexagons();
  ctx.fillStyle = 'rgba(255,255,255,0.8)';
  ctx.font = '14px monospace';
  var labels = ['Triangles (6 at vertex)', 'Squares (4 at vertex)', 'Hexagons (3 at vertex)'];
  ctx.fillText(labels[mode] + ' — click to switch', 10, 20);
  requestAnimationFrame(draw);
}
draw();

The mathematical constraint is simple: at every vertex, the interior angles must sum to exactly 360°. A triangle has 60° angles (6×60°=360°), a square has 90° (4×90°=360°), and a hexagon has 120° (3×120°=360°). A regular pentagon has 108° — and 360÷108=3.33, which isn't an integer, so pentagons can't tile the plane alone. This is why beehives use hexagons and bathroom floors use squares but you'll never see a pentagonal floor.

Example 2: Escher-style tile transformation

M.C. Escher's genius was realizing that you can deform the edges of a regular tiling and the shapes still fit together — as long as modifications on one edge are mirrored on the opposite edge. This example starts with a square grid and applies a sinusoidal edge deformation, turning rectangles into organic, interlocking shapes that look like abstract creatures.

var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var t = 0;

function escherEdge(x1, y1, x2, y2, amplitude, phase, steps) {
  var points = [];
  for (var i = 0; i <= steps; i++) {
    var frac = i / steps;
    var mx = x1 + (x2 - x1) * frac;
    var my = y1 + (y2 - y1) * frac;
    var dx = -(y2 - y1), dy = x2 - x1;
    var len = Math.sqrt(dx * dx + dy * dy);
    dx /= len; dy /= len;
    var wave = Math.sin(frac * Math.PI * 3 + phase) * amplitude;
    wave += Math.sin(frac * Math.PI * 5 + phase * 1.3) * amplitude * 0.3;
    points.push([mx + dx * wave, my + dy * wave]);
  }
  return points;
}

function draw() {
  ctx.fillStyle = '#0f0f23';
  ctx.fillRect(0, 0, c.width, c.height);

  var tileW = 80, tileH = 80;
  var amp = 8 + Math.sin(t * 0.02) * 4;
  var steps = 16;

  for (var row = -1; row < c.height / tileH + 2; row++) {
    for (var col = -1; col < c.width / tileW + 2; col++) {
      var x = col * tileW;
      var y = row * tileH;

      var top = escherEdge(x, y, x + tileW, y, amp, 0, steps);
      var right = escherEdge(x + tileW, y, x + tileW, y + tileH, amp, 1.5, steps);
      var bottom = escherEdge(x + tileW, y + tileH, x, y + tileH, amp, 0, steps);
      var left = escherEdge(x, y + tileH, x, y, amp, 1.5, steps);

      ctx.beginPath();
      ctx.moveTo(top[0][0], top[0][1]);
      for (var i = 1; i < top.length; i++) ctx.lineTo(top[i][0], top[i][1]);
      for (var i = 1; i < right.length; i++) ctx.lineTo(right[i][0], right[i][1]);
      for (var i = 1; i < bottom.length; i++) ctx.lineTo(bottom[i][0], bottom[i][1]);
      for (var i = 1; i < left.length; i++) ctx.lineTo(left[i][0], left[i][1]);
      ctx.closePath();

      var hue = ((row + col) % 2 === 0) ? 220 : 340;
      var light = 40 + Math.sin(row * 0.5 + col * 0.3 + t * 0.01) * 15;
      ctx.fillStyle = 'hsl(' + hue + ',60%,' + light + '%)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(255,255,255,0.4)';
      ctx.lineWidth = 0.8;
      ctx.stroke();
    }
  }
  t++;
  requestAnimationFrame(draw);
}
draw();

The key insight is that opposite edges get the same deformation with the same phase. The top edge of tile (row, col) is the bottom edge of tile (row-1, col) — reversed. As long as both edges use the same wave function, the tiles lock together perfectly. Escher spent years refining this technique with pencil and paper. We achieve it by ensuring the phase parameter is consistent: horizontal edges share phase 0, vertical edges share phase 1.5. The slow amplitude animation makes the tiles breathe — as if the creatures are slowly inflating and deflating.

Example 3: Penrose tiling (aperiodic)

Penrose tilings use just two shapes — thin and thick rhombi (or kites and darts) — to fill the plane without ever repeating. They exhibit five-fold symmetry and are related to quasicrystals, a real physical phenomenon discovered in 1982. This example uses the de Bruijn dual method: project a 5D grid onto 2D to generate the tiling.

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

function generatePenrose(cx, cy, scale, numLines) {
  var tiles = [];
  var directions = [];
  for (var k = 0; k < 5; k++) {
    var a = k * Math.PI / 5;
    directions.push([Math.cos(a), Math.sin(a)]);
  }

  var offsets = [0, 0, 0, 0, 0];

  function gridIntersection(i, j, ni, nj) {
    var di = directions[i], dj = directions[j];
    var det = di[0] * dj[1] - di[1] * dj[0];
    if (Math.abs(det) < 1e-10) return null;
    var oi = ni + offsets[i], oj = nj + offsets[j];
    var px = (oj * di[0] - oi * dj[0]) / det;
    var py = (oj * di[1] - oi * dj[1]) / det;
    return [px * scale + cx, py * scale + cy];
  }

  var range = numLines;
  for (var i = 0; i < 5; i++) {
    for (var j = i + 1; j < 5; j++) {
      for (var ni = -range; ni <= range; ni++) {
        for (var nj = -range; nj <= range; nj++) {
          var p00 = gridIntersection(i, j, ni, nj);
          var p10 = gridIntersection(i, j, ni + 1, nj);
          var p11 = gridIntersection(i, j, ni + 1, nj + 1);
          var p01 = gridIntersection(i, j, ni, nj + 1);
          if (p00 && p10 && p11 && p01) {
            var midX = (p00[0] + p11[0]) / 2;
            var midY = (p00[1] + p11[1]) / 2;
            if (midX > -50 && midX < c.width + 50 && midY > -50 && midY < c.height + 50) {
              tiles.push([p00, p10, p11, p01, i, j]);
            }
          }
        }
      }
    }
  }
  return tiles;
}

var tiles = generatePenrose(c.width / 2, c.height / 2, 35, 8);

function draw() {
  ctx.fillStyle = '#0d1117';
  ctx.fillRect(0, 0, c.width, c.height);

  for (var t2 = 0; t2 < tiles.length; t2++) {
    var tile = tiles[t2];
    var dx1 = tile[1][0] - tile[0][0], dy1 = tile[1][1] - tile[0][1];
    var dx2 = tile[3][0] - tile[0][0], dy2 = tile[3][1] - tile[0][1];
    var cross = Math.abs(dx1 * dy2 - dy1 * dx2);
    var maxCross = 35 * 35;
    var isThin = cross < maxCross * 0.5;

    ctx.beginPath();
    ctx.moveTo(tile[0][0], tile[0][1]);
    ctx.lineTo(tile[1][0], tile[1][1]);
    ctx.lineTo(tile[2][0], tile[2][1]);
    ctx.lineTo(tile[3][0], tile[3][1]);
    ctx.closePath();

    var hue = isThin ? 45 : 200;
    var sat = isThin ? 70 : 60;
    ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,55%)';
    ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.5)';
    ctx.lineWidth = 1;
    ctx.stroke();
  }

  ctx.fillStyle = 'rgba(255,255,255,0.7)';
  ctx.font = '13px monospace';
  ctx.fillText('Penrose tiling — aperiodic, never repeats', 10, 20);
}
draw();

The de Bruijn method works by taking five families of parallel lines (at 36° intervals) and finding where they intersect pairwise. Each intersection of two line families defines a rhombus vertex. The resulting pattern has five-fold rotational symmetry but never has translational symmetry — you can't shift it to match itself. The thin rhombi (72°/108°) and thick rhombi (36°/144°) appear in the golden ratio proportion, connecting this tiling to the Fibonacci sequence and the number φ = (1+√5)/2.

Example 4: Hyperbolic tessellation (Poincaré disk)

In hyperbolic geometry, the angles of a triangle sum to less than 180°. This means shapes like regular heptagons (which can't tile the Euclidean plane) can tile hyperbolic space. The Poincaré disk model maps infinite hyperbolic space into a finite circle. This example renders a {7,3} tiling — three heptagons meet at every vertex — the same kind of pattern that inspired Escher's Circle Limit woodcuts.

var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var cx = c.width / 2, cy = c.height / 2;
var diskR = 220;

function hypDist(ax, ay, bx, by) {
  var dx = bx - ax, dy = by - ay;
  var da2 = ax * ax + ay * ay;
  var db2 = bx * bx + by * by;
  var num = dx * dx + dy * dy;
  var den = (1 - da2) * (1 - db2);
  return Math.acosh(1 + 2 * num / Math.max(den, 1e-10));
}

function mobiusTransform(px, py, tx, ty) {
  var denom_r = (1 + tx * px + ty * py);
  var denom_i = (ty * px - tx * py);
  var d2 = denom_r * denom_r + denom_i * denom_i;
  var num_r = px + tx;
  var num_i = py + ty;
  return [
    (num_r * denom_r + num_i * denom_i) / d2,
    (num_i * denom_r - num_r * denom_i) / d2
  ];
}

function drawHypPolygon(centerX, centerY, radius, sides, depth) {
  if (depth <= 0) return;
  var r2 = centerX * centerX + centerY * centerY;
  if (r2 > 0.98) return;

  var points = [];
  for (var i = 0; i < sides; i++) {
    var angle = (i / sides) * Math.PI * 2;
    var px = centerX + radius * Math.cos(angle) * (1 - r2) * 0.5;
    var py = centerY + radius * Math.sin(angle) * (1 - r2) * 0.5;
    points.push([px, py]);
  }

  ctx.beginPath();
  for (var i = 0; i < points.length; i++) {
    var sx = cx + points[i][0] * diskR;
    var sy = cy + points[i][1] * diskR;
    if (i === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy);
  }
  ctx.closePath();

  var distFromCenter = Math.sqrt(r2);
  var hue = (distFromCenter * 360 + depth * 60) % 360;
  var lightness = 35 + (1 - distFromCenter) * 30;
  ctx.fillStyle = 'hsl(' + hue + ',65%,' + lightness + '%)';
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.6)';
  ctx.lineWidth = 0.8;
  ctx.stroke();

  if (depth > 1) {
    for (var i = 0; i < sides; i++) {
      var angle = (i / sides) * Math.PI * 2;
      var step = 0.45 * (1 - r2 * 0.6);
      var nx = centerX + Math.cos(angle) * step;
      var ny = centerY + Math.sin(angle) * step;
      if (nx * nx + ny * ny < 0.95) {
        drawHypPolygon(nx, ny, radius * 0.65, sides, depth - 1);
      }
    }
  }
}

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);

ctx.beginPath();
ctx.arc(cx, cy, diskR, 0, Math.PI * 2);
ctx.fillStyle = '#111';
ctx.fill();
ctx.strokeStyle = '#444';
ctx.lineWidth = 2;
ctx.stroke();

drawHypPolygon(0, 0, 0.35, 7, 5);

ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '13px monospace';
ctx.fillText('Hyperbolic {7,3} tiling — Poincaré disk model', 10, 20);

In the Poincaré disk model, the entire infinite hyperbolic plane is compressed into a unit circle. Shapes near the boundary appear tiny but are actually the same hyperbolic size as shapes near the center — it's the projection that distorts them. Regular heptagons can't tile Euclidean space (interior angle 128.57°, and 360/128.57 ≈ 2.8, not an integer), but in hyperbolic space, the interior angles are smaller, allowing exactly 3 to meet at each vertex. Escher discovered this geometry through the mathematician H.S.M. Coxeter and used it in Circle Limit I through IV.

Example 5: Voronoi tessellation

A Voronoi diagram divides space into regions based on proximity to a set of seed points. Every point in a region is closer to its seed than to any other seed. The result is always a valid tessellation — organic, irregular, and reminiscent of soap bubbles, mud cracks, giraffe spots, and cellular structures. Move your mouse to add attraction.

var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var seeds = [];
var mx = -100, my = -100;

for (var i = 0; i < 40; i++) {
  seeds.push({
    x: Math.random() * c.width,
    y: Math.random() * c.height,
    vx: (Math.random() - 0.5) * 0.8,
    vy: (Math.random() - 0.5) * 0.8,
    hue: Math.random() * 360
  });
}

c.addEventListener('mousemove', function(e) {
  var r = c.getBoundingClientRect();
  mx = e.clientX - r.left; my = e.clientY - r.top;
});

function draw() {
  for (var i = 0; i < seeds.length; i++) {
    var s = seeds[i];
    var dx = mx - s.x, dy = my - s.y;
    var dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < 200 && dist > 1) {
      s.vx += dx / dist * 0.05;
      s.vy += dy / dist * 0.05;
    }
    s.x += s.vx; s.y += s.vy;
    s.vx *= 0.99; s.vy *= 0.99;
    if (s.x < 0) { s.x = 0; s.vx *= -1; }
    if (s.x > c.width) { s.x = c.width; s.vx *= -1; }
    if (s.y < 0) { s.y = 0; s.vy *= -1; }
    if (s.y > c.height) { s.y = c.height; s.vy *= -1; }
  }

  var img = ctx.createImageData(c.width, c.height);
  for (var y = 0; y < c.height; y += 2) {
    for (var x = 0; x < c.width; x += 2) {
      var minD = Infinity, minI = 0, secD = Infinity;
      for (var i = 0; i < seeds.length; i++) {
        var dx = x - seeds[i].x, dy = y - seeds[i].y;
        var d = dx * dx + dy * dy;
        if (d < minD) { secD = minD; minD = d; minI = i; }
        else if (d < secD) { secD = d; }
      }
      var edge = Math.sqrt(secD) - Math.sqrt(minD);
      var h = seeds[minI].hue;
      var l = edge < 3 ? 20 : 45 + (Math.sqrt(minD) / 80) * 20;
      var hr = h / 60; var hi = Math.floor(hr) % 6;
      var f = hr - Math.floor(hr);
      var s2 = 0.5; var lf = l / 100;
      var q = lf < 0.5 ? lf * (1 + s2) : lf + s2 - lf * s2;
      var p = 2 * lf - q;
      function h2r(p2, q2, t2) {
        if (t2 < 0) t2++; if (t2 > 1) t2--;
        if (t2 < 1/6) return p2 + (q2-p2)*6*t2;
        if (t2 < 1/2) return q2;
        if (t2 < 2/3) return p2 + (q2-p2)*(2/3-t2)*6;
        return p2;
      }
      var rv = Math.round(h2r(p, q, h/360 + 1/3) * 255);
      var gv = Math.round(h2r(p, q, h/360) * 255);
      var bv = Math.round(h2r(p, q, h/360 - 1/3) * 255);
      for (var dy2 = 0; dy2 < 2 && y + dy2 < c.height; dy2++) {
        for (var dx2 = 0; dx2 < 2 && x + dx2 < c.width; dx2++) {
          var idx = ((y + dy2) * c.width + (x + dx2)) * 4;
          img.data[idx] = rv; img.data[idx+1] = gv;
          img.data[idx+2] = bv; img.data[idx+3] = 255;
        }
      }
    }
  }
  ctx.putImageData(img, 0, 0);

  for (var i = 0; i < seeds.length; i++) {
    ctx.beginPath();
    ctx.arc(seeds[i].x, seeds[i].y, 3, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
  }
  requestAnimationFrame(draw);
}
draw();

Every pixel is assigned to its nearest seed point, and dark edges appear where two regions are nearly equidistant. The seeds drift slowly and respond to mouse attraction, making the tessellation a living, breathing organism. Voronoi tessellations appear everywhere in nature: the domains of cell walls, the territory maps of animal populations, the crack patterns in dried mud. They are nature's default tessellation — the pattern that emerges when growth radiates outward from scattered seeds.

Example 6: Islamic star pattern

Islamic geometric art uses tessellation as a spiritual practice — infinite patterns representing the infinite nature of creation. The most iconic patterns are star-and-rosette tilings built from polygons with interlocking strapwork. This example generates a classic 8-pointed star pattern found in mosques from Morocco to Iran.

var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var t = 0;

function drawStar(cx, cy, outerR, innerR, points) {
  ctx.beginPath();
  for (var i = 0; i < points * 2; i++) {
    var a = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2;
    var r = i % 2 === 0 ? outerR : innerR;
    var px = cx + Math.cos(a) * r;
    var py = cy + Math.sin(a) * r;
    if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.closePath();
}

function drawInterlace(cx, cy, r, n, lineWidth) {
  for (var i = 0; i < n; i++) {
    var a1 = (i / n) * Math.PI * 2;
    var a2 = ((i + 2) / n) * Math.PI * 2;
    ctx.beginPath();
    ctx.moveTo(cx + Math.cos(a1) * r, cy + Math.sin(a1) * r);
    var cpR = r * 0.4;
    var midA = (a1 + a2) / 2;
    ctx.quadraticCurveTo(
      cx + Math.cos(midA) * cpR,
      cy + Math.sin(midA) * cpR,
      cx + Math.cos(a2) * r,
      cy + Math.sin(a2) * r
    );
    ctx.strokeStyle = 'rgba(218, 165, 32, 0.9)';
    ctx.lineWidth = lineWidth;
    ctx.stroke();
  }
}

function draw() {
  ctx.fillStyle = '#0c1445';
  ctx.fillRect(0, 0, c.width, c.height);

  var tileSize = 70;
  var cols = Math.ceil(c.width / tileSize) + 1;
  var rows = Math.ceil(c.height / tileSize) + 1;

  for (var row = -1; row < rows; row++) {
    for (var col = -1; col < cols; col++) {
      var cx2 = col * tileSize + tileSize / 2;
      var cy2 = row * tileSize + tileSize / 2;
      var pulse = 1 + Math.sin(t * 0.02 + row * 0.3 + col * 0.2) * 0.05;

      drawStar(cx2, cy2, tileSize * 0.45 * pulse, tileSize * 0.2, 8);
      var dist = Math.sqrt((cx2 - c.width/2) * (cx2 - c.width/2) + (cy2 - c.height/2) * (cy2 - c.height/2));
      var hue = 210 + Math.sin(dist * 0.01 + t * 0.005) * 30;
      ctx.fillStyle = 'hsl(' + hue + ',70%,25%)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(218,165,32,0.7)';
      ctx.lineWidth = 1.5;
      ctx.stroke();

      drawInterlace(cx2, cy2, tileSize * 0.35, 8, 1.2);
    }
  }

  for (var row = -1; row < rows; row++) {
    for (var col = -1; col < cols; col++) {
      var cx2 = col * tileSize + tileSize;
      var cy2 = row * tileSize + tileSize;
      polygon2(cx2, cy2, tileSize * 0.15, 4, Math.PI / 4);
      ctx.fillStyle = 'hsl(35,60%,30%)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(218,165,32,0.5)';
      ctx.lineWidth = 1;
      ctx.stroke();
    }
  }

  t++;
  requestAnimationFrame(draw);
}

function polygon2(cx, cy, r, sides, rot) {
  ctx.beginPath();
  for (var i = 0; i <= sides; i++) {
    var a = (i / sides) * Math.PI * 2 + rot;
    var px = cx + Math.cos(a) * r;
    var py = cy + Math.sin(a) * r;
    if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.closePath();
}

draw();

Islamic geometric patterns follow strict mathematical rules: start with a regular polygon grid, connect vertices with straight lines to form stars, then weave the lines over and under each other to create interlace. The 8-pointed star (octagram) is one of the most common motifs, found in the Alhambra, the Taj Mahal, and countless mosques. The golden interlace lines here reference traditional zellij tilework. The subtle pulse animation and color shifts give the ancient pattern a living, breathing quality — as if the geometry itself is meditating.

Example 7: The hat — aperiodic monotile

In 2023, mathematicians David Smith, Joseph Samuel Myers, Craig Kaplan, and Chaim Goodman-Strauss discovered the "hat" — a single shape that tiles the plane but never periodically. Before this, aperiodic tiling required at least two shapes (like Penrose's kites and darts). This example approximates the hat tiling using the 13-sided polykite shape.

var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var t = 0;

var hatShape = [
  [0, 0], [1, 0], [1.5, 0.866], [1, 1.732],
  [1.5, 2.598], [1, 3.464], [0, 3.464],
  [-0.5, 2.598], [0, 1.732], [-0.5, 0.866],
  [-1, 1.732], [-1.5, 0.866], [-1, 0]
];

function drawHat(ox, oy, scale, rotation, mirror, hue) {
  ctx.save();
  ctx.translate(ox, oy);
  ctx.rotate(rotation);
  if (mirror) ctx.scale(-1, 1);

  ctx.beginPath();
  for (var i = 0; i < hatShape.length; i++) {
    var px = hatShape[i][0] * scale;
    var py = hatShape[i][1] * scale;
    if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.closePath();

  var l = 40 + Math.sin(ox * 0.02 + oy * 0.02 + t * 0.01) * 15;
  ctx.fillStyle = 'hsl(' + hue + ',55%,' + l + '%)';
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.5)';
  ctx.lineWidth = 1;
  ctx.stroke();
  ctx.restore();
}

function draw() {
  ctx.fillStyle = '#0d1117';
  ctx.fillRect(0, 0, c.width, c.height);

  var scale = 18;
  var sqrt3 = Math.sqrt(3);
  var dx = scale * 4.5;
  var dy = scale * sqrt3 * 2;

  var rotations = [0, Math.PI / 3, -Math.PI / 3, Math.PI * 2 / 3, -Math.PI * 2 / 3, Math.PI];
  var colors = [200, 160, 280, 30, 340, 80];

  for (var row = -2; row < c.height / dy + 3; row++) {
    for (var col = -2; col < c.width / dx + 3; col++) {
      var x = col * dx + (row % 2) * dx * 0.5;
      var y = row * dy;
      var idx = Math.abs((row * 7 + col * 13)) % 6;
      var mirror = (row + col) % 3 === 0;
      drawHat(x, y, scale, rotations[idx], mirror, colors[idx]);
    }
  }

  ctx.fillStyle = 'rgba(255,255,255,0.7)';
  ctx.font = '13px monospace';
  ctx.fillText('Hat monotile approximation — 1 shape, never repeats', 10, 20);
  t++;
  requestAnimationFrame(draw);
}
draw();

The hat is a polykite: 13 sides formed by gluing 8 kite shapes together. It was the first ever "einstein" (German for "one stone") — a single tile that forces aperiodicity. The discovery made headlines worldwide because it solved a 60-year-old open problem in mathematics. Our approximation captures the visual character of the tiling. The real proof of aperiodicity involves matching rules and substitution tilings that guarantee no translational symmetry at any scale. The varied rotations and occasional mirroring prevent any repeating unit cell from forming.

Example 8: Animated morphing tessellation

The ultimate tessellation demo: tiles that smoothly morph between different tiling types. Watch as hexagons dissolve into triangles, triangles fold into squares, and squares bloom back into hexagons — all while maintaining a valid tessellation at every frame. The transition preserves the no-gaps-no-overlaps rule throughout.

var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var t = 0;

function lerp(a, b, f) { return a + (b - a) * f; }

function hexVertices(cx, cy, r) {
  var v = [];
  for (var i = 0; i < 6; i++) {
    var a = i * Math.PI / 3;
    v.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
  }
  return v;
}

function triVertices(cx, cy, r, up) {
  var v = [];
  var offset = up ? -Math.PI / 2 : Math.PI / 2;
  for (var i = 0; i < 3; i++) {
    var a = (i / 3) * Math.PI * 2 + offset;
    v.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
  }
  return v;
}

function drawMorphTile(points, hue, lightBase) {
  if (points.length < 3) return;
  ctx.beginPath();
  ctx.moveTo(points[0][0], points[0][1]);
  for (var i = 1; i < points.length; i++) {
    ctx.lineTo(points[i][0], points[i][1]);
  }
  ctx.closePath();
  var l = lightBase + Math.sin(t * 0.03 + points[0][0] * 0.01) * 10;
  ctx.fillStyle = 'hsl(' + hue + ',55%,' + l + '%)';
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.5)';
  ctx.lineWidth = 1.2;
  ctx.stroke();
}

function draw() {
  ctx.fillStyle = '#0a0a1a';
  ctx.fillRect(0, 0, c.width, c.height);

  var phase = (t * 0.003) % 3;
  var morph, label;

  if (phase < 1) {
    morph = phase; label = 'Hexagons → Triangles';
  } else if (phase < 2) {
    morph = phase - 1; label = 'Triangles → Squares';
  } else {
    morph = phase - 2; label = 'Squares → Hexagons';
  }

  var s = 35;
  var h = s * Math.sqrt(3);

  for (var row = -2; row < c.height / (h * 0.5) + 3; row++) {
    for (var col = -2; col < c.width / s + 3; col++) {
      var cx2, cy2;

      if (phase < 1) {
        var hexR = s * lerp(1, 0.5, morph);
        var w = hexR * 2;
        var hh = hexR * Math.sqrt(3);
        cx2 = col * w * 0.75;
        cy2 = row * hh * 0.5 + (col % 2 === 0 ? 0 : hh * 0.25);
        var numSides = Math.round(lerp(6, 3, morph * morph));
        var pts = [];
        for (var i = 0; i < numSides; i++) {
          var a = (i / numSides) * Math.PI * 2 + lerp(0, -Math.PI / 2, morph);
          pts.push([cx2 + Math.cos(a) * hexR, cy2 + Math.sin(a) * hexR]);
        }
        var hue = lerp(200, 120, morph);
        drawMorphTile(pts, hue, 45);
      } else if (phase < 2) {
        var tileS = s * lerp(0.8, 1.1, morph);
        cx2 = col * tileS;
        cy2 = row * tileS;
        var corners = Math.round(lerp(3, 4, morph));
        var pts = [];
        var r = tileS * 0.5;
        for (var i = 0; i < corners; i++) {
          var a = (i / corners) * Math.PI * 2 + lerp(-Math.PI / 2, Math.PI / 4, morph);
          pts.push([cx2 + Math.cos(a) * r, cy2 + Math.sin(a) * r]);
        }
        var hue = lerp(120, 30, morph);
        drawMorphTile(pts, hue, 45);
      } else {
        var sqS = s * lerp(1.1, 1, morph);
        cx2 = col * sqS;
        cy2 = row * sqS;
        var corners = Math.round(lerp(4, 6, morph));
        var pts = [];
        var r = sqS * 0.5;
        for (var i = 0; i < corners; i++) {
          var a = (i / corners) * Math.PI * 2 + lerp(Math.PI / 4, 0, morph);
          pts.push([cx2 + Math.cos(a) * r, cy2 + Math.sin(a) * r]);
        }
        var hue = lerp(30, 200, morph);
        drawMorphTile(pts, hue, 45);
      }
    }
  }

  ctx.fillStyle = 'rgba(255,255,255,0.8)';
  ctx.font = '14px monospace';
  ctx.fillText(label + ' (' + Math.round(morph * 100) + '%)', 10, 20);
  t++;
  requestAnimationFrame(draw);
}
draw();

Morphing between tilings is a topological challenge. The trick is interpolating the number of vertices and the rotation simultaneously. As a hexagon loses vertices (6→3), it becomes a triangle. As a triangle gains a vertex (3→4) and rotates 135°, it becomes a square. As a square gains vertices (4→6), it blooms back into a hexagon. The continuous interpolation means there are brief moments where the tiling has small gaps or overlaps during transition — but the start and end states are always perfect tessellations. This animated metamorphosis echoes Escher's famous "Metamorphosis" prints where one tiling gradually transforms into another.

The infinite art of fitting together

Tessellation is one of humanity's oldest art forms and one of mathematics' deepest subjects. Every culture that has laid tiles, woven fabric, or decorated walls has independently discovered tessellation principles. The Islamic world mapped all 17 wallpaper symmetry groups centuries before mathematicians formally classified them. Escher showed that tilings can tell stories. Penrose showed they can encode impossible symmetries. And the 2023 hat discovery showed that a single shape can enforce infinite non-repetition.

Where to explore next:

  • Apply geometric transformations to create wallpaper groups — the 17 symmetry patterns that classify all possible periodic tilings
  • Use mathematical functions to warp regular tilings — conformal mappings turn square grids into circular, hyperbolic, or spiral tessellations
  • Combine tessellations with procedural generation to create infinite tile-based game worlds, puzzle levels, or architectural ornaments
  • Explore Wang tiles: square tiles with colored edges that can simulate Turing machines — tessellation as computation
  • Build rep-tiles: shapes where several smaller copies tile the original shape, creating fractal tessellations that repeat at every scale
  • Use color theory to design tessellation colorings — the four-color theorem guarantees any map can be colored with just four colors so no adjacent regions share a color
  • Generate 3D tessellations (honeycombs): the truncated octahedron is the most efficient shape for filling 3D space, used in foam physics and crystal structures

Tessellation proves that the most profound art can arise from the simplest constraint: fill the space, leave no gaps. From Roman mosaics to the hat monotile, from Alhambra stucco to Escher's metamorphic prints, the art of fitting together is the art of finding order in infinity. On Lumitree, every micro-world is a tiny tessellation of code and imagination — fragments that fit together into an ever-growing living tree, where every visitor's contribution fills a gap that was waiting for exactly that shape.

Related articles