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

String Art: 9 Thread-Geometry Demos

Let thread hop between pins by a rule and curves appear on their own. A canvas engine that computes every wrap — copy-paste, zero libraries.

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

String art on a web page is usually a picture of string art. This post builds the working thing nine ways: each scene computes where every pin sits and which pin the thread jumps to next, then draws nothing but straight chords — and the overlaps brighten into curves on their own. Every item is one self-contained HTML file, paste-ready, zero libraries; the zip behind this post carries all nine plus a React port of each.

From Winding a Circle to Reading Photo Brightness: Widening the String Art Rule

The nine scenes share one pipeline and differ in where the pins come from and who picks the next thread. 01 is the pipeline itself, bare: 120 pins on a circle rim, one rule — hop 49 pins — and a rosette blooms out of 120 straight chords. 02 moves the pins onto two edges and shows the principle in its purest form: an arc floats up although no curve was ever drawn. 03 lets one parameter, the multiple k, decide which curve you get. 04 hands the pins to your pointer as Bezier control points. 05 and 06 are the pixel-computation pair — 05 lays thread where a portrait is dark, 06 scores every candidate chord and keeps only the one that helps. 07 pulls pins out of a leaf's outline instead of a circle, 08 crosses two thread directions into a loom weave, and 09 knits a two-letter wordmark under a spotlight as a front-door hero. Every item moved on 23 of 24 sampled frames. Painted area clears 30% on seven of the nine (01 through 07, landing in a 30–33% band); the two outliers sit above that band — 08 loom weave at 50.97%, the only layout whose two thread directions blanket the whole board, and 09 hero weave at 42.744%. The items separate on average intensity, where 04 Bezier threading is brightest at 62.5, and on peak frame, where 09 fills up to 43.653% in a single frame as its wrap nears the end.

# Item Technique Painted area Peak frame Avg intensity
01 Circle-pin basics N=120, SKIP=49, 120 threads, 2s 30.758% 30.051% 40.2
02 Curve illusion 32+32 edge pins, i ↔ M-1-i, 2s 30.674% 30.422% 40.5
03 Cardioid math k cycles 2·3·4, wrap 70% / hold 30% 33.049% 25.442% 31.9
04 Bezier control pins 3 control pins, fans of 128+96+84×2 30.737% 18.419% 62.5
05 Thread portrait 96² darkness grid, 4 candidates, 600 threads 30.144% 29.881% 51.4
06 Greedy wrapping 180 candidates scored, 190 picks, 72² grid 32.857% 32.465% 47.0
07 Edge pins from a leaf flood-fill boundary, 90 pins, 380 threads 32.144% 32.082% 35.9
08 Loom weave warp 26 × weft 18, phase gap 0.5, alpha 0.5 50.97% 32.46% 38.6
09 Hero weave 64 letter pins, 160 threads, radial spotlight 42.744% 43.653% 32.8

Code per item

Each fence below comes straight out of that item's own source — the real constants, the real identifiers — with the Korean comments and on-screen phrases carried into English. The 05 portrait cuts off where the drawing loop begins, and the zip carries the nine full files plus the React ports.

01Circle-pin basics — a fixed skip that blooms a flower

Pin 120 nails around a circle rim, connect pin i to pin i+49, repeat 120 times, and a rosette curve surfaces although every stroke is a straight chord — 30.758% painted area, 30.051% peak frame, 40.2 average intensity. Use it as the intro explainer of any string art generator: one board, one rule, everything visible.

Circle-pin basics — a fixed skip that blooms a flower
Open in a new tab

01 · Circle-pin basics — a fixed skip that blooms a flower — Measured area 30.685% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sinMath.coslineTobeginPath

Pin coordinates — equal angles around the rim

  function pinXY(i) {
    var a = Math.PI * 2 * i / N - Math.PI / 2;   // pin 0 sits at 12 o'clock, clockwise
    return [W / 2 + Math.cos(a) * R, H / 2 + Math.sin(a) * R];
  }

The wrap loop — chords, recency brightness, nothing else

    var total = N;                      // one lap = N threads (i → i+SKIP, N times)
    var seg = progress * total;
    var full = Math.floor(seg);
    var frac = seg - full;

    ctx.lineCap = 'round';
    ctx.shadowBlur = 5;                 // thread sheen — halo adds to overlap brightness
    for (var i = 0; i < full; i++) {
      var a = pinXY(i), b = pinXY(i + SKIP);
      // threads near the wrap front are bold — where the hand just passed glows
      var recency = (full - i) / total;           // 1 (just laid) → 0 (first)
      var alpha = 0.34 + 0.62 * Math.pow(recency, 1.6);
      ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
      ctx.shadowColor = 'rgba(' + COL_GLOW.join(',') + ',' + (alpha * 0.5).toFixed(3) + ')';
      ctx.lineWidth = 1 + recency * 1.4;
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }

02Curve illusion — an envelope that bends arcs from straight thread

Move the pins off the circle onto two edges: 32 on the left, 32 along the bottom, left pin i crossed to bottom pin (M-1-i). Every chord is tangent to one and the same arc — the canonical construction of a curve as the envelope of straight lines, at 30.674% painted area, 30.422% peak frame, 40.5 average intensity.

Curve illusion — an envelope that bends circles from straight thread
Open in a new tab

02 · Curve illusion — an envelope that bends circles from straight thread — Measured area 30.669% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineTobeginPathstroke

Two edge rails, evenly divided

  function pinL(i) { return [pad, pad + (H - 2 * pad) * i / (M - 1)]; }
  // bottom-edge pin j (left to right) — uses the board's full width
  function pinB(j) { return [pad + (W - 2 * pad) * j / (M - 1), H - pad]; }

Cross-wiring the two rails

    for (var i = 0; i < M; i++) {
      if (i / M >= progress) continue;
      var a = pinL(i), b = pinB(M - 1 - i);
      var recency = (progress * M - i) / M;      // 1 (just wrapped) → 0 (first)
      var alpha = 0.55 + 0.42 * Math.pow(recency, 1.4);
      ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
      ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
      ctx.lineWidth = 1.8 + recency * 1.6;
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }
    ctx.shadowBlur = 0;

03The cardioid — the math of doubling pins on a circle

One rule — wrap pin n to pin k×n — decides which curve you get: k=2 a cardioid, k=3 a nephroid, k=4 a three-petal epicycloid, each phase wrapping across its first 70% and holding the last 30% so the re-knit stays watchable. 33.049% painted area, 25.442% peak frame, 31.9 average intensity.

The cardioid — the math of doubling pins on a circle
Open in a new tab

03 · The cardioid — the math of doubling pins on a circle — Measured area 33.918% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sinMath.coslineTostrokeStyle

The phase clock that retunes k

    var phase = (t / CYCLE) % 3;            // 0..3 — one k per phase
    var k = 2 + Math.floor(phase);          // 2, 3, 4
    var local = phase - Math.floor(phase);  // progress inside the phase, 0..1
    var label = 'rule k = ' + k + ' · curve ' + RULE_NAMES[k - 2];

    // wrap across the first 70%, hold for the last 30% (time to see the full curve)
    var progress = Math.min(1, local / 0.7);
    var total = N;                          // thread count visible at once
    var full = Math.floor(progress * total);

The multiply rule, with the vertex side highlighted

    for (var i = 0; i < full; i++) {
      var a = pinXY(i), b = pinXY((i * k) % N);
      // emphasize chords closing on the center (near the envelope's vertex) + recency
      var mx = (a[0] + b[0]) / 2 - W / 2, my = (a[1] + b[1]) / 2 - H / 2;
      var near = 1 - Math.min(1, Math.sqrt(mx * mx + my * my) / R);
      var recency = (full - i) / total;
      var hot = near > 0.55;
      var alpha = (hot ? 0.72 : 0.58) + 0.26 * Math.pow(recency, 1.5);
      var base = hot ? COL_HOT : COL_THRD;
      ctx.strokeStyle = 'rgba(' + base.join(',') + ',' + Math.min(0.97, alpha).toFixed(3) + ')';
      ctx.shadowColor = 'rgba(' + COL_HOT.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
      ctx.lineWidth = (hot ? 1.6 : 1.2) + recency * 1.1;
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }
    ctx.shadowBlur = 0;

04Bezier threading — drag a control pin and the thread rewraps

Chain end pins A and B to three control pins (C1, C2, C3) and thread equal-ratio points of the chain together — a de Casteljau construction whose thread envelope follows the Bezier curve, draggable live through pointer events, retargeting itself every 1.5 seconds when left alone. This is the brightest item at 62.5 average intensity — thick 2.4-to-4.2px threads and strong recency contrast — with 30.737% painted area and an 18.419% peak frame, the lowest in the set, because the wrap retraces a narrow band rather than flooding the board.

Bezier threading — drag a control pin and the thread rewraps
Open in a new tab

04 · Bezier threading — drag a control pin and the thread rewraps — Measured area 30.967% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineTobeginPathpointermove

The two outer fans, lerped point to point

    for (i = 0; i < FAN; i++) {
      u = i / (FAN - 1);
      if (u > progress) continue;
      a = px(lerp(A, C1, u)); b = px(lerp(C3, B, u));
      recency = Math.max(0, 1 - (progress - u));
      alpha = 0.44 + 0.5 * Math.pow(recency, 1.1);
      ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
      ctx.lineWidth = 2.4 + recency * 1.8;
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }

Dragging a control pin rethreads the wrap

  cvEl.addEventListener('pointerdown', function (e) {
    var p = evPos(e);
    drag = hit(p[0], p[1]);
    if (drag >= 0) {
      try { cvEl.setPointerCapture(e.pointerId); } catch (err) { /* drag survives a failed capture */ }
      e.preventDefault();
    }
  });
  cvEl.addEventListener('pointermove', function (e) {
    if (drag < 0) return;
    var p = evPos(e);
    var rx = Math.max(0.06, Math.min(0.94, p[0] / W));
    var ry = Math.max(0.10, Math.min(0.90, p[1] / H));
    cur[drag] = [rx, ry]; tgt[drag] = [rx, ry];
    e.preventDefault();
  });

Left alone, the pins move themselves every 1.5s

    // retarget every 1.5s — the control pins move on their own to show the recompute
    var pi = Math.floor(t / RECOMPUTE) % PRESETS.length;
    for (var k = 0; k < NCTRL; k++) {
      if (drag !== k) tgt[k] = PRESETS[pi][k];
      cur[k][0] += (tgt[k][0] - cur[k][0]) * 0.09;
      cur[k][1] += (tgt[k][1] - cur[k][1]) * 0.09;
    }

    // rewrap across the first 85% of the period; the pin glide keeps moving after
    var progress = Math.min(1, (t % RECOMPUTE) / RECOMPUTE / 0.85);
    draw(t, progress);

05Thread portrait — brightness rewritten as thread density

No external image: the portrait silhouette (head circle, neck, shoulder curve, cheek shading, corner columns) is painted procedurally on an offscreen canvas and read back as a 96×96 darkness grid, and each of the 600 threads connects to whichever of four candidate pins (1–4× skip 74) passes through the darker path — a schedule built once, so thread piles only where the portrait is dark. 30.144% painted area, 29.881% peak frame, 51.4 average intensity.

Thread portrait — brightness rewritten as thread density
Open in a new tab

05 · Thread portrait — brightness rewritten as thread density — Measured area 31.687% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDatagetImageDatalineTorequestAnimationFrame

The portrait, painted with no image file

    o.fillStyle = '#fff'; o.fillRect(0, 0, SS, SS);   // bright ground
    o.fillStyle = '#000';
    // bottom corner columns — dark photo frame; keeps thread at the bottom so the board never empties
    o.fillRect(0, SS * 0.80, SS * 0.20, SS * 0.20);
    o.fillRect(SS * 0.80, SS * 0.80, SS * 0.20, SS * 0.20);
    // head — a large circle, upper center
    o.beginPath(); o.arc(SS * 0.50, SS * 0.30, SS * 0.155, 0, Math.PI * 2); o.fill();
    // neck
    o.fillRect(SS * 0.455, SS * 0.40, SS * 0.09, SS * 0.10);
    // hair shading — an upper half-moon, darker (overlaid)
    o.beginPath(); o.arc(SS * 0.50, SS * 0.325, SS * 0.158, Math.PI, Math.PI * 2); o.fill();

Sampling a chord's darkness at 7 interior points

  function chordDark0(a, b) {
    var s = 0, n = 7;
    for (var k = 1; k <= n; k++) {
      var u = k / (n + 1);
      var x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
      var gx = Math.round(x), gy = Math.round(y);
      if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
      s += lum[gy * SS + gx];
    }
    return s / n;
  }

The greedy schedule, built once and never re-rolled

  var schedule = [];
  (function plan() {
    var pos = 0, used = {};
    for (var s = 0; s < STR; s++) {
      var bestJ = -1, bestV = -1;
      var base = pinXY0(pos);
      for (var c = 1; c <= 4; c++) {
        var j = (pos + c * SKIP) % N;
        var key = pos + '-' + j;
        if (used[key]) continue;
        var v = chordDark0(base, pinXY0(j));
        if (v > bestV) { bestV = v; bestJ = j; }
      }
      if (bestJ < 0) bestJ = (pos + SKIP) % N;
      used[pos + '-' + bestJ] = 1;
      schedule.push([pos, bestJ, Math.min(1, bestV * 1.6)]);
      pos = bestJ;
    }
  })();

06Greedy wrapping — the optimization that picks the next thread

Keep a target pattern (a five-point star stroked in white offscreen) as a 72×72 goal grid and the woven state as a same-size board; each step lays every one of 180 candidate chords onto the board, rolls it back, scores sum((saturated ink − goal)²), and keeps only the single thread that lowers the score most. 190 of those picks draw a star out of thread alone, and when no candidate improves, a 0.37N skip-rule thread keeps the weave moving — 32.857% painted area, 32.465% peak frame, 47.0 average intensity.

Greedy wrapping — the optimization that picks the next thread
Open in a new tab

06 · Greedy wrapping — the optimization that picks the next thread — Measured area 33.037% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDataImageDatalineTorequestAnimationFrame

Laying a chord onto the board — and taking it back

  function paintChord(i, j, sign) {
    var a = gPin(i), b = gPin(j);
    var K = 60;
    for (var k = 0; k <= K; k++) {
      var u = k / K;
      var x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
      var gx = x | 0, gy = y | 0;
      if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
      var idx = gy * SS + gx;
      board[idx] += sign * 0.5;
    }
  }

The score, with saturation clamped at 1

  // how far the board is from the goal — smaller means more alike
  function score() {
    var s = 0;
    for (var i = 0; i < SS * SS; i++) {
      var ink = board[i] > 1 ? 1 : board[i];   // overlapping threads saturate
      var diff = ink - goal[i];
      s += diff * diff;
    }
    return s;
  }

One greedy step — try every candidate, keep the best

  function greedyStep() {
    var base = score();
    var i0 = woven.length ? woven[woven.length - 1][1] : 0;   // continue from the previous end pin
    var bestJ = -1, bestSc = base;
    for (var c = 1; c <= CAND; c++) {
      var j = (i0 + c) % N;
      paintChord(i0, j, 1);
      var sc = score();
      paintChord(i0, j, -1);
      if (sc < bestSc) { bestSc = sc; bestJ = j; }
    }
    if (bestJ >= 0) {
      paintChord(i0, bestJ, 1);
      woven.push([i0, bestJ]);
      pinsUsed[i0]++; pinsUsed[bestJ]++;
      return bestJ;
    }
    // no candidate improves — lay one rule thread anyway to keep the weave moving
    var fallback = (i0 + Math.round(N * 0.37)) % N;
    paintChord(i0, fallback, 1);
    woven.push([i0, fallback]);
    pinsUsed[i0]++; pinsUsed[fallback]++;
    return fallback;
  }

07Edge pins — pulling pin positions out of a picture's outline

Draw a leaf motif in two flat tones offscreen, then flood-fill the white background from the outside — only the points where it touches the dark body form the outer boundary, and the veins, enclosed by dark, never qualify. Sorted by angle, sampled evenly into 90 pins, stretched 1.7× horizontally to fill the wide board, the boundary becomes the pin rail for an outline lap and 7, 11, 16-pin skip laps that pack the body — 32.144% painted area, 32.082% peak frame, 35.9 average intensity.

Edge pins — pulling pin positions out of a picture's outline
Open in a new tab

07 · Edge pins — pulling pin positions out of a picture's outline — Measured area 32.166% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDatagetImageDatalineTobeginPath

A leaf in two flat tones, so the boundary stays crisp

    // two flat tones instead of a gradient — the boundary must stay sharp for accurate pins
    o.fillStyle = '#fff'; o.fillRect(0, 0, SS, SS);
    o.fillStyle = '#000';
    // leaf body — a plump leaf joined from two quadratic curves (wide, for wrap area)
    o.beginPath();
    o.moveTo(SS * 0.50, SS * 0.06);
    o.quadraticCurveTo(SS * 1.02, SS * 0.46, SS * 0.50, SS * 0.94);
    o.quadraticCurveTo(SS * -0.02, SS * 0.46, SS * 0.50, SS * 0.06);
    o.closePath(); o.fill();

Binarize at 128 and flood-fill for the outer boundary

    // only the outer boundary — flood-fill the white background from outside (BFS);
    // the points where it touches the dark body are the outline. The veins, cut off
    // from the outside by dark body, never qualify as pins.
    var dark = new Uint8Array(SS * SS);
    for (var b0 = 0; b0 < SS * SS; b0++) dark[b0] = lum[b0] < 128 ? 1 : 0;
    var seen = new Uint8Array(SS * SS);
    var bnd  = new Uint8Array(SS * SS);
    var stack = [0];
    seen[0] = 1;
    var NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
    while (stack.length) {
      var q = stack.pop();
      var qx = q % SS, qy = (q - qx) / SS;
      for (var d4 = 0; d4 < 4; d4++) {
        var nx = qx + NX[d4], ny = qy + NY[d4];
        if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
        var qi = ny * SS + nx;
        if (dark[qi]) bnd[q] = 1;            // white pixel beside the body → outline point
        else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
      }
    }

Sort by angle, sample 90 pins, stretch the board wide

    var ring = [];
    for (var r0 = 0; r0 < SS * SS; r0++) if (bnd[r0]) ring.push([r0 % SS, (r0 - r0 % SS) / SS]);
    // sort by angle, then take NPIN pins at even spacing — the wrap follows the shape
    var cxg = SS / 2, cyg = SS / 2;
    ring.sort(function (a, b) {
      return Math.atan2(a[1] - cyg, a[0] - cxg) - Math.atan2(b[1] - cyg, b[0] - cxg);
    });
    var stepR = ring.length / NPIN;
    for (var p = 0; p < NPIN; p++) {
      var q2 = ring[Math.floor(p * stepR) % ring.length];
      PINS.push([(q2[0] / SS - 0.5) * 1.12 + 0.5, (q2[1] / SS - 0.5) * 1.12 + 0.5]);
    }
  })();

  var CX = 0, CY = 0;
  function pinPx(i) {
    var u = PINS[i % PINS.length];
    var s = Math.min(W, H);
    // stretch wide — the canvas (480×300) is wider than tall, so the leaf spreads to fill it
    return [CX + (u[0] - 0.5) * s * 1.7, CY + (u[1] - 0.5) * s * 1.02];
  }

Outline lap first, then 7·11·16-pin skip laps

    for (var i = 0; i < full; i++) {
      // lap 1 (n threads) joins neighbors to draw the leaf border; later laps cycle
      // 7·11·16-pin skips to fill the body (the envelope packs the leaf)
      var K = i < n ? 1 : [7, 11, 16][(i - n) % 3];
      var a = pinPx(i % n), b = pinPx((i + K) % n);
      var recency = (full - i) / STR;
      var alpha = 0.6 + 0.34 * Math.pow(recency, 1.2);
      ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
      ctx.lineWidth = 3.1 + recency * 2.2;
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }

08Loom weave — grid patterns born where threads cross

Lay 26 vertical warp threads and 18 horizontal weft threads, each a 14-segment polyline wobbling ±3.5px along its normal, the weft streaming a phase gap of 0.5 behind the warp; overlaid at translucent globalAlpha, the crossings brighten and the moiré settles into a disciplined pattern tile. This is the painted-area outlier at 50.97% — two thread directions blanket the whole board while every other item gathers thread inside a disc or a shape — with a 32.46% peak frame and 38.6 average intensity.

Loom weave — grid patterns born where threads cross
Open in a new tab

08 · Loom weave — grid patterns born where threads cross — Measured area 50.901% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sinlineToglobalAlpharequestAnimationFrame

One thread — a straight run that wobbles along its normal

  function thread(x0, y0, x1, y1, wob, t) {
    var K = 14;
    ctx.beginPath();
    ctx.moveTo(x0, y0);
    for (var k = 1; k <= K; k++) {
      var u = k / K;
      var x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u;
      // wobble along the normal of travel — the thread's tension sway
      var dx = x1 - x0, dy = y1 - y0;
      var len = Math.sqrt(dx * dx + dy * dy) || 1;
      var nx = -dy / len, ny = dx / len;
      var off = Math.sin(u * Math.PI * 2 + t * 2) * wob;
      ctx.lineTo(x + nx * off, y + ny * off);
    }
    ctx.stroke();
  }

Warp first, weft delayed by the phase gap

    // warp (vertical) — phase 0. Laid left to right.
    for (i = 0; i < doneW; i++) {
      u = i / (NW - 1);
      recency = Math.max(0, 1 - (progress - u));
      alpha = BASE * (0.72 + 0.5 * Math.pow(recency, 1.2));
      ctx.globalAlpha = Math.min(0.95, alpha);
      ctx.strokeStyle = 'rgb(' + COL_THRD.join(',') + ')';
      ctx.lineWidth = 2.6 + recency * 1.8;
      thread(pad + iw * u, pad, pad + iw * u, H - pad, 3.5, t);
    }

    // weft (horizontal) — streams a phase gap PHASE behind
    var pF = Math.max(0, progress - PHASE) / (1 - PHASE);
    var doneF2 = Math.floor(pF * NF);
    for (i = 0; i < doneF2; i++) {
      u = i / (NF - 1);
      recency = Math.max(0, 1 - (pF - u));
      alpha = BASE * (0.72 + 0.5 * Math.pow(recency, 1.2));
      ctx.globalAlpha = Math.min(0.95, alpha);
      ctx.strokeStyle = 'rgb(' + COL_CROSS.join(',') + ')';
      ctx.lineWidth = 2.6 + recency * 1.8;
      thread(pad, pad + ih * u, W - pad, pad + ih * u, 3.5, t + PHASE * 6);
    }
    ctx.globalAlpha = 1;

09Hero weave — a front door knitting the brand in thread

Stamp a two-letter wordmark large on an offscreen canvas, flood-fill the outside to keep only the outer contour rings, split them into connected components (the G ring, the S ring), and order 64 pins around the rings. The first lap traces the contour in bold strokes with only the between-letters bridge kept faint at alpha 0.18, later laps fill each letter sparsely with 5, 9, 13-pin skips inside that letter's own pin range, and a radial-gradient spotlight of radius max(W,H)×0.42 rises behind the finished wordmark — 42.744% painted area, a 43.653% peak single frame near the wrap's end (the highest in the set), 32.8 average intensity.

Hero weave — a front door knitting the brand in thread
Open in a new tab

09 · Hero weave — a front door knitting the brand in thread — Measured area 43.626% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sinlineTocreateRadialGradientrequestAnimationFrame

Contour rings only — the outside flood fill decides

    // of the stamped letter pixels, keep only the OUTER contour — flood-fill from outside
    // (BFS) and mark what it touches. Interior pixels never connect to the outside, so
    // they are excluded and only a thin contour ring remains
    var ink = new Uint8Array(SS * SS);
    for (var i0 = 0; i0 < SS * SS; i0++) ink[i0] = d[i0 * 4] > 128 ? 1 : 0;
    var seen = new Uint8Array(SS * SS);
    var isIn = new Uint8Array(SS * SS);
    var stack = [0]; seen[0] = 1;
    var NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
    while (stack.length) {
      var q = stack.pop();
      var qx = q % SS, qy = (q - qx) / SS;
      for (var d4 = 0; d4 < 4; d4++) {
        var nx = qx + NX[d4], ny = qy + NY[d4];
        if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
        var qi = ny * SS + nx;
        if (ink[qi]) isIn[q] = 1;   // an outside pixel beside a letter → contour point
        else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
      }
    }

The bold contour lap and the faint letter bridge

      if (i < n) {
        // first lap — join neighboring pins to trace the letter contour boldly (G ring → S ring).
        // only the one thread where the component changes stays faint and thin
        var bridge = PINC[i] !== PINC[(i + 1) % n];
        var a1 = pinPx(i), b1 = pinPx((i + 1) % n);
        ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + (bridge ? 0.18 : 0.92) + ')';
        ctx.lineWidth = bridge ? 1.0 : 2.7;
        if (bridge) ctx.shadowBlur = 0;
        ctx.beginPath(); ctx.moveTo(a1[0], a1[1]); ctx.lineTo(b1[0], b1[1]); ctx.stroke();
        if (bridge) ctx.shadowBlur = 7;
      } else {

Sparse fill, kept inside each letter's pin range

        // from the second lap on — sparse fill with 5·9·13-pin skips, INSIDE each letter only.
        // no long thread crosses a letter, so the bundle stays letter-shaped
        var f = i - n;
        var run = RUNS[f % RUNS.length];
        var m = run[2] - run[1];
        var c = Math.floor(f / RUNS.length);
        var K2 = [5, 9, 13][c % 3] % Math.max(2, m - 1);
        var q0 = run[1] + (c % m);
        var q1 = run[1] + ((c % m) + K2) % m;
        var a2 = pinPx(q0), b2 = pinPx(q1);
        var recency = (full - i) / STR;
        var alpha = 0.22 + 0.3 * Math.pow(recency, 1.1);
        ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
        ctx.lineWidth = 1.3;
        ctx.beginPath(); ctx.moveTo(a2[0], a2[1]); ctx.lineTo(b2[0], b2[1]); ctx.stroke();
      }

The spotlight that rises with the wrap

    // spotlight behind — a createRadialGradient lighting the finished wordmark
    var lit = 0.5 + 0.5 * progress;
    var sx = W / 2 + Math.sin(t * 0.9) * W * 0.06;
    var sy = H * 0.53 + Math.cos(t * 0.7) * H * 0.05;
    var rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(W, H) * 0.42);
    rg.addColorStop(0, 'rgba(' + COL_GLOW.join(',') + ',' + (0.5 * lit).toFixed(3) + ')');
    rg.addColorStop(1, 'rgba(' + COL_GLOW.join(',') + ',0)');
    ctx.fillStyle = rg;
    ctx.fillRect(0, 0, W, H);

Skip counts that aren't coprime are where this breaks

N and the skip count must be coprime

Item 01 runs N=120 against SKIP=49. Because the two share no common divisor, one lap of i → i+SKIP passes through every one of the 120 pins before returning to the start. Give them a common divisor — N=120 with SKIP=40 — and the wrap closes after just 3 hops, back at its starting pin, the lap touching only 3 of the 120 pins; the envelope grows holes where the rosette should be. This is the first thing that breaks in every circle-rim string art generator, and it fails loudly enough to catch in one look.

Drawing the curve directly kills the principle

The temptation is to help the picture along with one arc or one quadraticCurveTo where the curve "should" be. The moment a real curve is drawn, the scene stops being string art — it becomes a drawing decorated with straight lines. The discipline is total: the code computes pins and chords, the envelope does the rest, and not one bend is ever stroked.

Re-planning the greedy schedule per frame jitters the portrait

Items 05 and 06 build their schedules once, before the loop starts, so every cycle redraws the identical picture. Rebuild the schedule every frame and two problems arrive at once: the portrait flickers as threads change their minds, and the cost explodes — item 06 scores 180 candidates against a 72×72 board per pick, 190 picks per cycle, and doing that 60 times a second is a different program. Deterministic is not a style choice here; it is what lets the scene repeat identically forever.

Skipping the saturation clamp in item 06's score

Board values above 1 must clamp to 1 before scoring. Unclamped, one heavily wrapped spot keeps improving its own brightness forever — the greedy loop piles every thread onto the same few pins and the rest of the board goes bare. The clamp maps thread density to pattern brightness with a ceiling, which is what lets the star's arms and its center coexist.

Promoting inner strokes to pins dissolves the outline

Items 07 and 09 keep only the boundary the outer flood fill can reach. Make the leaf's veins or the letters' interior strokes into pins and the wrap starts cutting across the interior immediately — the outline you were tracing dissolves into a shaggy blob within one lap. The rule is mechanical, not aesthetic: a pin exists only where white background outside the shape touches dark body, or where the outside touches a letter stroke.

A gradient background defeats the binarizing threshold

Item 07 hunts its boundary by binarizing brightness at 128 — dark body against white ground. Paint the ground as a gradient and the threshold no longer separates the two: parts of the background fall under 128, parts of the body sit above it, and the pin ring wanders off the shape. Two flat tones keep the boundary pixel-crisp, which is exactly why the source draws them that way instead of reaching for the prettier gradient. The wrap itself never slips when the rule laps cycle — the 7·11·16 skips of item 07 and the 5·9·13 skips of item 09 are what keep thread density even inside the shape. Treat this set as a small string art generator with ready-made string art patterns for every item — the fences above retypable, the zip carrying the nine full source files plus the React ports behind this post, opening with 77qjdznz — and keep its one law: straight chords only, curves by crowding.

Three variants, one wired straight into reduced-motion accessibility

Three knobs to turn

Name Change Feel
Skip count item 01 SKIP 49 → 2 a thread that hopped barely two pins now takes the long way around the rim; the rosette's petals grow huge and the pattern turns sparse and graphic
Pin count item 03 pins 120 → 360 three times the pins smooth the cardioid's envelope, but the crossings start smearing where threads overlap
Thread opacity item 08 BASE alpha 0.5 → 0.25 the more translucent the thread, the darker the crossings get, and the weave's contrast flips — a lacier tile from one number

Reduced motion freezes a finished wrap, except one teaching frame

Every scene checks prefers-reduced-motion before scheduling its loop, and when the preference is set the loop never starts: the scene draws one fixed frame, freezes, and the root takes an is-still class. Eight items draw their fully wrapped board — the finished envelope, portrait, star, leaf, weave, or wordmark — while item 03 stops a third of the way into its first wrap, a cardioid with the wrapping hand still visible, so the still picture still shows that a wrap is happening.

One pointer item, eight that run alone

Item 04 listens to pointerdown, pointermove, and pointerup over its control pins; the other eight run on requestAnimationFrame alone. Every canvas host carries an aria-label and a tabindex, keeping the boards keyboard-reachable, and each board reports its measurement meta — pins, threads, cycle seconds — in one line under the canvas.

Where this actually gets used — a hero wordmark and a photo-portrait card

Item 09 winds thread between letter-shaped pins, so the finished scene is a brand wordmark that can sit in a landing page's hero slot as-is. Showing the wrap mid-progress while the rest of the page loads, then cutting to the next screen the instant it completes, makes a wait feel shorter than a bare spinner does.

Item 05 turns a portrait's brightness into thread density, which fits an about-page photo card or a profile picture that reads as art rather than a plain photograph. Item 07 keeps only a picture's outline, so dropping it behind a product shot leaves the object recognizable without the photo itself ever appearing.

All three only need a different source image behind the same pin math — the wrap loop and canvas size stay exactly as written.

Questions before you wire up nine string art boards

Can I use these string art patterns on a personal site without a library

Yes. Each item is one self-contained HTML file — a plain canvas 2D context, sin and cos for the pins, moveTo and lineTo for the thread, getImageData where a scene reads brightness. No CDN, no WebGL, no external image; even the portrait in item 05 and the letters in item 09 are stamped procedurally offscreen. Paste the file into any page and it runs as-is, and the zip adds a React port of every scene for component-tree projects.

How does a string art generator decide which pin the thread goes to next

Three ways, one per family. A fixed rule hops a set number of pins — item 01's skip 49, item 03's multiple k — and the curve is decided before the first thread is laid. A crossing rule pairs two rails the way item 02 pairs left pin i with bottom pin M-1-i. A greedy pick measures candidates: item 05 samples the darkness each chord passes through, item 06 scores every candidate against the goal grid and keeps the best. The rules get more expensive in that order, and all three stay deterministic so the picture repeats.

Why do my curves look faint when I copy the code

Usually the recency brightness has been flattened. These scenes draw every thread at a low base alpha and reserve the bright end for freshly laid strands, so the wrap front stays visible while old thread recedes into the weave; level the alphas and the envelope loses its density gradient and reads as gray mush. The two things to check are the alpha formula (base + boost × recency) and the shadowBlur halo, which adds to the overlap brightness where threads crowd — the exact places the curve lives.

Sources: MDN — CanvasRenderingContext2D.moveTo() · MDN — CanvasRenderingContext2D.lineTo() · MDN — CanvasRenderingContext2D.beginPath() · MDN — Window.requestAnimationFrame() · Wikipedia — String art · Wikipedia — Envelope (mathematics)

Looking back — why straight threads read as curves

No curve is drawn anywhere in the nine scenes

Every thread in items 01 through 07 is one moveTo and one lineTo — a straight chord from pin to pin. (Item 08's threads are wobbling 14-segment polylines, still straight segments joined end to end; 09's contour and fill laps are the same single chord each.) Pins themselves are tiny circles drawn with ctx.arc, but the thread paths never bend. The arcs, cardioids, and rosettes you see are not strokes: they are the places where chords happen to pass close together and brighten. The eye, handed a field of tangent-ish straight lines, completes the boundary they crowd around — and that perceived boundary is the curve.

The envelope is where the crowd is densest

Mathematically, the family of chords in item 02 has an envelope: a curve that every chord touches and that no chord crosses. Thread i from the left rail crosses thread j from the bottom rail at a point, and those intersection points march along a smooth arc as i advances. The intersections are where threads pile on top of each other, so they accumulate the most ink — the density peak and the envelope are the same line. Nothing is computed about the curve itself; the wrap rule computes chords, and the curve is a by-product of their crowding.

One rule per curve

This is why a single parameter decides the whole picture. Item 01's skip count fixes the chord family and therefore the rosette; item 02's crossing rails fix the arc; item 03's multiple k picks cardioid, nephroid, or epicycloid. If you want a different curve you do not draw a different curve — you change where the pins sit or how the thread hops, and the envelope follows. It is the same family as the other computed scenes on this site: a depth field that hides a solid inside noise, a sphere that appears only where a marched ray stops, a network a slime mold grows between food sites. Each computes something other than the picture you end up seeing.

Enter the archive password

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