GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Kaleidoscope Drawing: 9 Canvas Symmetry Tools

Draw one stroke and reflection matrices fill every remaining wedge. Mirror angle, symmetry order and hue cycling are yours — one canvas, zero libraries, full

Auto-plays · click a tile to jump to its section · all nine in one zip

Demo

A kaleidoscope drawing tool earns the name only while the hand is in it: the moment one segment arrives — from your pointer on pointermove, or from the seeded autoplay — a reflection matrix stamps that single segment into every wedge of the disc, 2n strokes computed in the same frame, and the pattern you watch is the arithmetic itself, not a picture of it. Nine boards share that one core and widen it in sequence: a base brush, an order slider that rebuilds the whole path, a hue that travels with the distance drawn, polar stamps that snap to a radial grid, an image feed sliced into the mirrors, a wedge-clipped six-fold snowflake, rangoli wreaths assembled from dots and curves, a record-and-replay loop, and a repeating-tile export. The measurements keep the same honesty. Item 09's 3×3 tiling repaints the widest footprint at 25.892 percent cumulative area, with item 04's rosette stamps at 19.003 and item 05's image bands at 13.488; at the thin end, item 03's hue brush covers 0.556 percent, because a 5 px brush stacking one segment at a time fills almost nothing — a structural property of line art, not a defect. Brightness runs the other way: the hue brush is the brightest board at 148.4 average intensity, the replay loop close behind at 147.0, and the tiled wallpaper darkest at 26.3 as its grain flows across a pale stage. In the 24-frame capture, eight boards moved on 23 frames and the stamp board on 22.

Nothing here rolls dice: the code contains no random call at all, and every autoplay brush is a parameter curve — three brushes orbiting 2π/3 apart in item 01, a closing spiral in item 08, 46 woven curves feeding item 05 — so the pattern you verify is the pattern every visitor gets. That distinction also tells you what to look for in a kaleidoscope drawing online: a board whose pointer joins the computation through the same reflection function is a tool, and one that only plays back is a recording. Interact where the chips invite you — draw directly on 01, 02, 03, and 06, click 04 to stamp a node, drag 05 and 09 to steer the band and the grain phase, change shapes and patterns from the selects, and restart item 08's recording with its button.

The 24-frame measurements at a glance

# Board Focus Painted area Peak frame Avg intensity Frames moved
01 Base brush setTransform reflection, 2n = 16 5.295% 0.699% 125.5 23
02 Order slider stored segments, change recompute 0.975% 0.390% 129.1 23
03 Hue-cycle brush path length into the hsl angle 0.556% 0.321% 148.4 23
04 Polar stamp ring and spoke snap, 0.12 s interval 19.003% 5.593% 80.6 22
05 Image feed drawImage band, 2n placements 13.488% 9.228% 66.9 23
06 Six-fold snowflake 60-degree wedge clipping 0.714% 0.689% 122.9 23
07 Rangoli grid ring × spoke dots, curve links 2.192% 0.338% 116.7 23
08 Record and replay coordinate record, interpolated playback 5.417% 1.944% 147.0 23
09 Wallpaper export quadrant rotations, toDataURL 25.892% 10.350% 26.3 23

Painted area is the share of frame pixels the brush actually touched; average intensity is measured contrast; frames moved counts how many of the 24 captured frames changed at all.

Why the line-art boards measure small areas

A drawing tool's screen only grows where the brush has been, so items 02, 03, 06, and 07 — 0.556 to 2.192 percent — are showing the true size of thin 1.4-to-5 px strokes laid one segment at a time. The wide end of the set earns its numbers differently: item 04 fills rosettes of stamp shapes across 48 grid nodes, item 05 drags full-height image bands through every wedge, and item 09 repaints a 3×3 field of finished tiles every frame. What keeps the thin boards alive is intensity and motion — 116.7 to 148.4 average intensity, far above the floor of 15, and movement in nearly every captured frame.

No random numbers anywhere

Each board's autoplay is a fixed parameter function of time, so reloading always reproduces the same pattern — item 01's three brushes phase apart, item 02's tour of orders, item 03's spiral, item 06's crystal growth, item 08's closing loop. The seed is the function itself: no generator to initialize, no stream to consume, nothing to drift.

The nine boards run in one arc: define the reflection core first (01), bind it to a symmetry order (02), hang color on the distance drawn (03), then raise the tool's grade — stamps that complete the symmetry for you (04), a feed that draws images instead of lines (05), the folded-wedge discipline of a paper snowflake (06), wreaths built from dots and curves (07), a process you can rewind (08), and finally a file you can keep (09).

Code per tool in nine steps

Every fence below is lifted verbatim from that board's index.html — the same constants and identifiers the running demos use, with the Korean source comments rendered in English — and no fence is shortened with a placeholder; the password archive carries the nine complete files plus a React port of each.

Where these nine boards actually get used

A workshop or online-class landing page needs its hero already moving before a visitor scrolls, and item 01's seeded brushwork never stops on its own — drop that board behind the fold and the page never shows a static first screen. A livestream or music-visualizer overlay needs the opposite property, a field that keeps reacting for as long as the show runs, which is what makes item 03's hue-cycle brush a fit — feed chat events or beat timing into the same pointer path and the background answers in real time. A seasonal promo banner or gift-card page benefits from item 06's six-fold snowflake, since a single wedge redrawn through the clip is cheap enough to recolor per campaign without touching the fold math underneath.

Why symmetry is computed the moment you draw — reflection matrices

The mirror does not copy the finished picture; it relocates the brush. Every stroke in this set passes through one call — setTransform carrying the four matrix arguments (c, s, s, −c) and then the two translations — where c and s are the cosine and sine of a = k·2π/n. That 2×2 matrix [[c, s], [s, −c]] is a rotation multiplied by a y-axis reflection, determinant −1, and the sign is the whole trick: neighboring wedges meet as mirror images and interlock at their spokes, while rotation alone would leave every wedge the same way up and the screen would read as a fan of pasted tiles. Cycling k from 0 to n−1 turns one segment into 2n strokes — the reflected pairs ride along because the mirror axis itself turns with k — and after the loop the transform resets to the identity so the next stroke starts in plain screen coordinates. Doing all of it at draw time rather than as a post-process is what buys the simultaneity: the instant your segment bends, all 2n copies bend in the same frame, and that liveness is the difference between a kaleidoscope and a collage. This site keeps other canvases built on the same law — a string-art board whose rosette emerges from winding order rather than drawn curves, a sand field re-decided one cell at a time as it pours — computed drawings, not painted ones.

Reference specs: MDN — CanvasRenderingContext2D.setTransform · MDN — CanvasRenderingContext2D.transform · MDN — CanvasRenderingContext2D.lineTo · MDN — Pointer events · MDN — CanvasRenderingContext2D.toDataURL · MDN — aria-label.

Every fence below is lifted verbatim from that board's index.html — the same constants and identifiers the running demos use, with the Korean source comments rendered in English — and no fence is shortened with a placeholder; the password archive carries the nine complete files plus a React port of each.

01The base kaleidoscope brush — one stroke reflected 2n times

Every pointermove hands one previous-point-to-current-point segment to mirroredSeg, which stamps it at n angles through the rotation-and-flip matrix while three autoplay brushes orbit at phases 2π/3 apart, resuming 1.6 s after your hand leaves the board.

The base kaleidoscope brush — one stroke reflected 2n times
Open in a new tab

01 · The base kaleidoscope brush — one stroke reflected 2n times — Measured area 5.295% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

setTransformlineTo

One matrix that is a rotation and a mirror at once

The four matrix arguments (c, s, s, −c) multiply a rotation by a y-axis flip — determinant −1 — so adjacent wedges face each other as mirror images and meet exactly at the spoke, and after the loop the identity transform (dpr, 0, 0, dpr, 0, 0) hands the canvas back to screen coordinates.

  function mirroredSeg(x0, y0, x1, y1, ci) {
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      ctx.strokeStyle = COLS[ci % 3];
      ctx.lineWidth = 6;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    strokes += 1;
    cnt.textContent = String(strokes);
  }

Three brushes on one deterministic path

pathAt is a pair of sine sums — no random number anywhere — so the autoplay reproduces the same orbits on every visit and pauses the moment a pointerdown starts, and the stroke counter you see climbing counts real reflected segments.

  function pathAt(t, ph) {
    var ang = t * 1.55 + ph + 0.9 * Math.sin(t * 0.31 + ph);
    var r = R * (0.30 + 0.34 * Math.sin(t * 0.47 + ph * 2) + 0.20 * Math.sin(t * 0.13 + ph));
    if (r < R * 0.07) r = R * 0.07;
    return [cx + r * Math.cos(ang), cy + r * Math.sin(ang)];
  }

  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var t = 0, lastInput = -9, pen = false, penPt = null;
  var PH = [0, 2.09, 4.19];        // three brushes, phases 2π/3 apart

02Symmetry order slider — change n and the pattern rebuilds

Drawn segments are stored as [x0, y0, x1, y1, color] rows, and the slider's change event — read once, after the thumb is released — clears the canvas and re-stamps the whole array at fresh angles with the wedge angle 360/n shown alongside, while the autoplay tours n = 3, 6, 12, 18, 24, switching every 4 seconds.

Symmetry order slider — change n and the pattern rebuilds
Open in a new tab

02 · Symmetry order slider — change n and the pattern rebuilds — Measured area 0.975% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

setTransformchange

Why change and not input

Listening to input would recompute on every intermediate value and shake the pattern all through the drag; change fires a single time on release, so the rebuild lands in one frame and the board holds still while you decide.

  function drawSeg(s) {
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), sn = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * sn, dpr * sn, -dpr * c, cx * dpr, cy * dpr);
      ctx.strokeStyle = s[4] ? C2 : C1;
      ctx.lineWidth = 5;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(s[0], s[1]); ctx.lineTo(s[2], s[3]); ctx.stroke();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

The stored array outlives any order

The bitmap is pixels with no memory of the angles that painted it, but the segment array holds order-free absolute coordinates, so setN can rebuild the identical drawing under a different n without the brush ever passing again.

  function setN(n) {
    N = n;
    val.textContent = String(n);
    deg.textContent = String(Math.round(360 / n));
    order.value = String(n);
    redraw();
  }
  // the change event fires once, after the thumb is released — reading input
  // instead would recompute mid-drag and shake the pattern the whole way
  order.addEventListener('change', function () { setN(parseInt(order.value, 10) || 6); });

03Hue-cycle brush — color travels with distance drawn

The accumulated path length feeds hsl(pathLen × 0.35, 34%, 74%), so the hue axis advances as the stroke grows, and a trail fade that lays 8 percent stage color over the frame each tick — an equivalent decay of 0.92 — leaves the older strokes drifting out in tails.

Hue-cycle brush — color travels with distance drawn
Open in a new tab

03 · Hue-cycle brush — color travels with distance drawn — Measured area 0.556% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

hsllineTo

Distance becomes color

mirroredSeg measures each incoming segment with the Pythagorean root before stamping it and updates the hue and length readouts in the same pass, so the number climbing in the chip is the number picking the color.

  function brushColor() {
    var h = (pathLen * 0.35) % 360;
    return 'hsl(' + h.toFixed(0) + ', 34%, 74%)';
  }

  function mirroredSeg(x0, y0, x1, y1, col) {
    var dx = x1 - x0, dy = y1 - y0;
    pathLen += Math.sqrt(dx * dx + dy * dy);
    hue.textContent = String(Math.round((pathLen * 0.35) % 360));
    len.textContent = String(Math.round(pathLen));
    ctx.globalAlpha = 0.9;
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      ctx.strokeStyle = col;
      ctx.lineWidth = 5;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.globalAlpha = 1;
  }

The fade is a fill, not an alpha

Erasing with a destination-out composite would thin the strokes themselves; painting a translucent stage rectangle melts old marks toward the background while every fresh segment stays at full strength.

  function fade() {
    // trail decay — 8 percent stage color over the whole frame each tick,
    // an equivalent decay of 0.92, melts old strokes toward the background
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.globalAlpha = 0.08;
    ctx.fillStyle = STAGE;
    ctx.fillRect(0, 0, W, H);
    ctx.globalAlpha = 1;
  }

04Polar tile stamp — stamps snapping to a radial grid

The cursor lands wherever it lands, and snap rounds it to a ring and spoke on a grid of 4 rings × 12 spokes — odd rings shifted half a cell so the rosette fills — with three stamp shapes of five petals, an eight-point star, and three dots chosen from a select, while the autoplay places one node every 0.12 s and restarts after three laps — 144 stamps in all.

Polar tile stamp — stamps snapping to a radial grid
Open in a new tab

04 · Polar tile stamp — stamps snapping to a radial grid — Measured area 19.003% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

arclineTo

Rounding radius and angle before reflecting

A stamp that misses its grid node reads as doodle rather than rangoli, so the radius rounds to the nearest ring width and the angle to the nearest multiple of 2π/n before any matrix touches it.

  function snap(px, py) {
    var dx = px - cx, dy = py - cy;
    var r = Math.min(RINGS, Math.max(1, Math.round(Math.hypot(dx, dy) / (R / RINGS))));
    var k = Math.round(Math.atan2(dy, dx) / (2 * Math.PI / N));
    k = ((k % N) + N) % N;
    var a = k * 2 * Math.PI / N;
    return [cx + r * (R / RINGS) * Math.cos(a), cy + r * (R / RINGS) * Math.sin(a), r, k];
  }

Rings pass through, spokes reflect

Rings are already symmetric, so the radius needs no mirroring — stampAt reflects the point across the spokes only, walking it through the same rotation-and-flip matrix the line tools use.

  function stampAt(px, py, ci) {
    var p = snap(px, py);
    var aBase = p[3] * 2 * Math.PI / N;
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      var lx = (p[0] - cx), ly = (p[1] - cy);
      var mx = lx * c + ly * s, my = lx * s - ly * c;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      drawStamp(cx + mx, cy + my, 9 + p[2] * 1.6, ci, aBase + k * 0.2);
    }
    count += 1; marks.textContent = String(count);
  }

05Feeding a photo — streaming image slices into the mirrors

An offscreen 320×240 canvas woven from 46 parameter curves plays the source image, and each wedge k receives one band of it — width SW/n = 64 px — wrapped in by drawImage, with even wedges passing through unchanged and odd wedges flipped so neighbors interlock, while the band position drifts as 0.5 + 0.48·sin(t × 0.21) or follows your drag directly.

Feeding a photo — streaming image slices into the mirrors
Open in a new tab

05 · Feeding a photo — streaming image slices into the mirrors — Measured area 13.488% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

drawImagesetTransform

The flip lives inside the matrix

Multiplying the second and fourth matrix arguments by −1 on odd wedges turns the same band into its mirror image, which is why adjacent slices meet along the spoke without a seam.

  function render(p) {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = STAGE;
    ctx.fillRect(0, 0, W, H);
    var band = SW / N;                        // the strip each wedge owns
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      // wedge flip (reflection) — the same band mirrored so neighbors interlock
      var flip = (k % 2 === 0) ? 1 : -1;
      ctx.setTransform(dpr * c, dpr * s * flip, dpr * s, -dpr * c * flip, cx * dpr, cy * dpr);
      var sx = ((p * (SW - band) + k * band * 0.13) % SW + SW) % SW;
      // wrap the band as if around a cone: height becomes radius
      ctx.drawImage(src, sx, 0, band, SH, -R * 0.18, -R, R * 0.36, R * 2);
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    pos.textContent = String(Math.round(p * 100));
  }

A source cheap enough to redraw

paintSource costs 46 stroked curves on a small canvas, which is why the feed can be treated as live material — the band you drag through it is cut from a picture that exists only as computation.

  function paintSource() {
    sc.fillStyle = STAGE;
    sc.fillRect(0, 0, SW, SH);
    for (var i = 0; i < 46; i++) {
      var ph = i * 1.7;
      sc.strokeStyle = [C1, C2, C3][i % 3];
      sc.lineWidth = 2 + (i % 5);
      sc.beginPath();
      for (var y = 0; y <= SH; y += 8) {
        var x = SW / 2 + (SW / 2 - 8) * Math.sin(y * 0.013 + ph) * Math.cos(y * 0.005 + ph * 0.5);
        if (y === 0) sc.moveTo(x, y); else sc.lineTo(x, y);
      }
      sc.stroke();
    }
  }

06Six-fold snowflake — folding paper without paper

Draw anywhere on the disc and foldPoint maps the point into a single 60-degree wedge starting at −90°, where every stroke is stamped six times through the fold matrix under a wedge clip that cuts at the boundary — the rule of paper folded six times and trimmed with scissors.

Six-fold snowflake — folding paper without paper
Open in a new tab

06 · Six-fold snowflake — folding paper without paper — Measured area 0.714% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

setTransformlineTo

The clip plays the scissors

A beginPath-arc-clip in local coordinates means a stroke crossing the wedge edge is cut there, and its mirror continues from the far side, exactly where folded paper would show the cut.

  function mirroredSeg(x0, y0, x1, y1, ci) {
    for (var k = 0; k < N; k++) {
      var a = A0 + k * WEDGE, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      // clip to the wedge (0 <= angle <= WEDGE, local coords) — strokes
      // crossing the boundary are cut, the rule of folded paper
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.arc(0, 0, R + 2, 0, WEDGE);
      ctx.closePath();
      ctx.clip();
      ctx.strokeStyle = ci ? C2 : C1;
      ctx.lineWidth = 4.5;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x0 - cx, y0 - cy); ctx.lineTo(x1 - cx, y1 - cy); ctx.stroke();
      ctx.restore();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    strokes += 1;
    cnt.textContent = String(strokes);
  }

Folding any angle into the first wedge

Six-fold symmetry maps every direction onto the one sector — an angle past the half turn reflects back as 2·span − rel — so wherever the pen wanders, the drawing itself happens inside the fold.

  function foldPoint(px, py) {
    var dx = px - cx, dy = py - cy;
    var a = Math.atan2(dy, dx);
    var r = Math.min(R, Math.hypot(dx, dy));
    // fold the angle into [A0, A0+WEDGE] — six-fold symmetry maps any
    // direction onto this one wedge
    var span = 2 * Math.PI / N;
    var rel = ((a - A0) % (2 * span) + 2 * span) % (2 * span);
    if (rel > span) rel = 2 * span - rel;         // past half a turn, reflect
    var a2 = A0 + rel;
    return [cx + r * Math.cos(a2), cy + r * Math.sin(a2)];
  }

07Rangoli grid — weaving symmetric wreaths from dots

On a polar grid of 4 rings × 16 spokes, pt computes each position from the ring and spoke numbers — the zigzag pattern shifts every other ring by half a cell — and the autoplay drops one dot every 0.045 s until 64 fill the field, then lays connecting curves at 0.06 s each — 48 links spanning the rings — rests 2 s, and moves to the next of three patterns.

Rangoli grid — weaving symmetric wreaths from dots
Open in a new tab

07 · Rangoli grid — weaving symmetric wreaths from dots — Measured area 2.192% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arcchange

Placement by index, not by eye

Because pt turns two integers into a position, symmetry is arithmetic — the same spoke number on every ring lines up exactly, which freehand placement never manages.

  function pt(ring, k) {
    var off = (pattern === 1 && ring % 2 === 1) ? 0.5 : 0;   // zigzag: half a cell per ring
    var r = R * (ring + 1) / (RINGS + 1);
    var a = (k + off) * 2 * Math.PI / N;
    return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
  }

Curves chosen per pattern

drawLink picks its target spoke by pattern — neighbors for petals, half-steps for the zigzag, a jump of n/5 for the star net — and bows each connection with a single quadratic control point.

  function drawLink(ring, k) {
    var a0 = pt(ring, k), a1;
    var step = pattern === 2 ? Math.round(N / 5) : (pattern === 1 ? 0.5 : (ring % 2 === 0 ? 1 : -1));
    if (pattern === 1) {
      a1 = pt(ring + 1, k + ((k % 2 === 0) ? 0.5 : -0.5));
    } else {
      a1 = pt(ring + 1, k + step);
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.strokeStyle = C2; ctx.lineWidth = 1.4;
    ctx.beginPath();
    ctx.moveTo(a0[0], a0[1]);
    var mx = (a0[0] + a1[0]) / 2 + (a1[1] - a0[1]) * 0.35;
    var my = (a0[1] + a1[1]) / 2 - (a1[0] - a0[0]) * 0.35;
    ctx.quadraticCurveTo(mx, my, a1[0], a1[1]);
    ctx.stroke();
  }

08Record and replay — a loop that rewinds your drawing

Recording pushes [u, x, y] every frame across a 3.0 s lap whose spiral closes on its own start point, and replay re-stamps the record at the same pace through linear interpolation between the two nearest points, a green cursor dot riding along while the color alternates each lap.

Record and replay — a loop that rewinds your drawing
Open in a new tab

08 · Record and replay — a loop that rewinds your drawing — Measured area 5.417% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramesetTransformarc(

Why the record stores progress, not pixels

A bitmap cannot be played back at another speed, but a u-parameterized path can — interpolation needs coordinates, and the closing spiral guarantees the loop never breaks mid-path.

  function recStep(du) {
    recU += du;
    var u = Math.min(1, recU / REC_DUR);
    var prev = record.length ? [record[record.length - 1][1], record[record.length - 1][2]] : null;
    var p = pathAt(u);
    record.push([u, p[0], p[1]]);
    pts.textContent = String(record.length);
    if (prev) mirroredSeg(prev[0], prev[1], p[0], p[1], C1);
    fill.style.width = String(Math.round(u * 100)) + '%';
    if (u >= 1) { phase = 1; playU = 0; playPrev = null; clear(); }
  }

Interpolating between the two nearest points

playStep converts elapsed time back to u, finds the bracketing record entries, and blends them by the fractional part, so playback speed is decoupled from the frame rate it was recorded at.

  function playStep(du) {
    playU += du;
    var u = playU / REC_DUR;
    if (u >= 1) { loops += 1; loop.textContent = String(loops); playU = 0; playPrev = null; u = 0; }
    var col = (loops % 2 === 0) ? C2 : C1;
    // interpolate between the two record points bracketing u
    var i = Math.min(record.length - 1, Math.floor(u * (record.length - 1)));
    var a = record[i], b = record[Math.min(record.length - 1, i + 1)];
    var f = (u * (record.length - 1)) - i;
    var x = a[1] + (b[1] - a[1]) * f, y = a[2] + (b[2] - a[2]) * f;
    if (playPrev) mirroredSeg(playPrev[0], playPrev[1], x, y, col);
    playPrev = [x, y];
    // replay cursor — the green dot
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = C3;
    ctx.beginPath(); ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.fill();
    fill.style.width = String(Math.round(u * 100)) + '%';
  }

09Wallpaper export — turning the pattern into a repeating tile

A quadrant canvas paints seven grain lines whose phase p flows with your drag, an assembly canvas places that quadrant four times through 0°, 90°, 180°, and 270° rotations so the corners connect, the finished tile fills a 3×3 grid, and toDataURL encodes it into the preview as a genuinely repeating background.

Wallpaper export — turning the pattern into a repeating tile
Open in a new tab

09 · Wallpaper export — turning the pattern into a repeating tile — Measured area 25.892% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

toDataURLfillRect

Seven grain lines on one quadrant

Each line is a sine whose phase shifts with p, stroked at 13 points across the quadrant — cheap enough to repaint every frame while you drag.

  function paintQuadrant(p) {
    qc.setTransform(1, 0, 0, 1, 0, 0);
    qc.clearRect(0, 0, q.width, q.height);
    qc.scale(dpr, dpr);
    for (var i = 0; i < 7; i++) {
      var ph = i * 1.9 + p * 6.28;
      qc.strokeStyle = [C1, C2, C3][i % 3];
      qc.lineWidth = 1.6 + (i % 3) * 0.7;
      qc.beginPath();
      for (var s = 0; s <= 12; s++) {
        var x = S * s / 12;
        var y = S * (0.2 + 0.6 * (0.5 + 0.5 * Math.sin(s * 0.55 + ph)));
        if (s === 0) qc.moveTo(x, y); else qc.lineTo(x, y);
      }
      qc.stroke();
    }
  }

Four rotations, then a data URL

Each 90-degree placement meets its neighbor along an edge drawn by the same curves, so the wrapped tile has no visible joint — and toDataURL turns the assembled canvas into a PNG the preview can wear as wallpaper.

  function assembleTile() {
    var qd = q.width;                  // quadrant device px — square
    tc.setTransform(1, 0, 0, 1, 0, 0);
    tc.clearRect(0, 0, tw.width, tw.height);
    tc.drawImage(q, 0, 0);                                    // top-left 0°
    tc.setTransform(0, 1, -1, 0, qd * 2, 0); tc.drawImage(q, 0, 0);   // top-right 90°
    tc.setTransform(-1, 0, 0, -1, qd * 2, qd * 2); tc.drawImage(q, 0, 0); // bottom-right 180°
    tc.setTransform(0, -1, 1, 0, 0, qd * 2); tc.drawImage(q, 0, 0);   // bottom-left 270°
    tc.setTransform(1, 0, 0, 1, 0, 0);
  }
  function render(p) {
    paintQuadrant(p);
    assembleTile();
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = STAGE;
    ctx.fillRect(0, 0, W, H);
    var tileS = S * 2;
    for (var gy = 0; gy < NY; gy++) {
      for (var gx = 0; gx < NX; gx++) {
        ctx.drawImage(tw, gx * tileS, gy * tileS, tileS, tileS);
      }
    }
    // export — encode the finished tile and plant it in the preview background
    var url = tw.toDataURL('image/png');
    tile.style.backgroundImage = 'url(' + url + ')';
  }

Changing the symmetry order — why patterns collapse and rebuild

Change n and the wedge boundaries move — but the canvas is a pile of pixels that remembers nothing about the angles that painted it, so leaving the bitmap as-is would hang the old symmetry underneath the new one until enough fresh strokes buried it. Item 02 answers by trusting coordinates over pixels: every segment lands on a strokes array as it is drawn, and when the slider's change event fires, setN clears the canvas and re-stamps the entire array through the reflection matrix at the new angles — one frame of work, so the rebuild reads as the pattern quietly standing up again rather than collapsing. It is also a reliable tell about the whole class of tool: a symmetry drawing online that survives its own order slider is recomputing from stored coordinates, and one that smears is only moving pixels.

It is also where this post's archive proves its worth: it holds all nine boards and a React port of each, opens with mqv4cpdt, and continues every fence above into complete, uncut files.

Skip the y-flip and every mirror breaks into a fan

Dropping the y-flip leaves a fan, not a fold

Drop the minus sign on the fourth matrix argument — write (c, s, s, c) — and the reflection is gone: what remains is rotation without the y-flip, every wedge the same way up, and the eye reads the result as a patchwork of turned tiles rather than a kaleidoscope. The identity of the form is alternation — rotation and reflection taking turns — and in the matrix (c, s, s, −c) that single sign carries all of it.

Recomputing without clearing stacks two orders

When n changes, redrawing at fresh angles without clearing first leaves the old pattern living underneath at the old angles, and two symmetries overlay until the board is mud. The stored segment array is still perfect — the failure lives on screen only — which is why the first act of every rebuild is the eraser.

A bare remainder splits the polar grid

atan2 hands back negative angles, and a remainder operator that follows the sign of its dividend will produce a negative spoke index near twelve o'clock, splitting the grid into two mismatched halves; the fold ((k % N) + N) % N is what keeps every index inside 0 to n−1.

Three one-value variants, tuned for reduced-motion accessibility

Three changes of one value each

Variant Change What it changes
Symmetry order 02 order 8 → 16 doubling the order makes the same strokes read more ornate, but each wedge halves in width and the calm of the empty field thins away
Brush jitter 01 jitter 0 px → 3 px a little hand tremor warms the geometry with a drawn-by-hand feel, while too much blurs the mirror until the kaleidoscope reads as a sketch
Stroke decay 03 trail 1.0 → 0.92 erasing faintly every frame leaves the strokes flowing with tails, while at 1.0 every moment simply accumulates

Measured across the set, the hue-cycle brush runs the brightest board at 148.4 average intensity with the replay loop close at 147.0, and the tiled wallpaper reads darkest at 26.3 — pale stage, flowing grain, by design.

What reduced-motion users receive

Every board checks prefers-reduced-motion at startup, and a reduce match stops the loop and pre-computes one finished still instead: item 01 pre-draws four seconds of brushwork, item 02 settles at order 12, item 03 fills its spiral turns, item 04 stamps all 48 grid nodes, items 05 and 09 render the pattern at position 0.35, item 06 grows five seconds of branches, item 07 completes its dots and curves, and item 08 shows the finished record with one replay lap. Turning the motion off never means erasing the information.

Screen readers and keyboards

Each field carries role="application" with a human-readable aria-label and a tabindex, pointer capture is wrapped in try/catch so a stroke survives the pointer leaving the canvas, sliders and selects commit on change, and the live counters — parts, strokes, path length, stamp count, replay laps — are text a screen reader meets as it changes.

Questions people ask before opening the nine boards

Is this kaleidoscope drawing set runnable by copy and paste?

Yes — each board is one self-contained index.html with no library, no framework, no CDN, and no font dependency, so dropping a file into a browser tab runs that tool exactly as it runs above. The fences are the exact functions from those files, and the password archive carries all nine boards plus nine React ports built on the same reflection core.

Why does the pattern rebuild instead of smearing when the order changes?

Because the tool never trusts the bitmap to hold the geometry. Segments are stored as coordinates the moment they are drawn, and an order change clears the canvas and re-stamps the whole array through the reflection matrix at fresh angles — recomputation inside a single frame, which is what item 02's slider demonstrates every four seconds.

How do I save the finished pattern as an image file?

Item 09 already walks the whole path: the finished tile is encoded with toDataURL and planted into the preview's background. Pointing an anchor element's download attribute at that same data URL turns the preview into a click-to-save wallpaper, and because the four-rotation assembly makes opposite edges match, the saved tile repeats without seams.

Enter the archive password

The password is inside this article. You will find it as you read.