String Art: How to Create Beautiful Mathematical String Patterns With Code
String art is the craft of stretching straight lines between fixed points to form curved shapes. No actual curves exist in a finished piece. Every line is perfectly straight. But the eye fills in the gaps, and what you see are smooth parabolas, cardioids, and spirals built entirely from intersecting chords.
The technique dates back further than most people realise. Mary Everest Boole invented "curve stitching" in the late 1800s to teach children how straight lines could describe curves. She punched holes in cardboard and had students thread coloured string between them. By the 1960s, string art had become a popular craft—hammering nails into wood and winding thread between them to make geometric wall decorations. You can still find vintage kits on eBay.
But the real magic is in the maths. A cardioid appears when you connect point n to point 2n around a circle. A nephroid appears at 3n. Parabolic envelopes form when you connect evenly spaced points on two lines. These are envelope curves—the family of straight lines creates a boundary that traces a smooth mathematical curve, even though no single line follows that curve.
For creative coders, string art is a perfect subject. The algorithms are short. The visual payoff is high. And the parameter space is enormous—change the multiplier on a circle from 2 to 2.1 and the cardioid warps into something alien. In this guide we build eight interactive string art programs, from a basic cardioid to a full generative composition. Every example is self-contained, runs on a plain HTML Canvas with no libraries, and stays under 50KB. For related techniques, see the Bézier curves guide, the math art guide, or the geometric art guide.
Setting up
Every example uses this minimal HTML setup:
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
// ... example code goes here ...
</script>
Paste each example into the script section. All code is vanilla JavaScript with the Canvas 2D API.
1. Cardioid envelope: the classic string art circle
The most famous string art pattern. Place points evenly around a circle. Connect each point n to point 2n (wrapping around). The straight lines trace out a cardioid—a heart-shaped curve that appears inside the circle as if by magic. The multiplier 2 is key: it maps each point to its double, and the envelope of all those chords is exactly the cardioid from polar coordinates r = 1 + cos(θ).
This example draws the construction step by step so you can watch the cardioid emerge from individual straight lines.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const cx = W / 2, cy = H / 2, R = 340;
const N = 200;
const multiplier = 2;
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
// Draw the circle of points
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.stroke();
// Draw dots at each point
for (let i = 0; i < N; i++) {
const a = (i / N) * Math.PI * 2;
const x = cx + R * Math.cos(a);
const y = cy + R * Math.sin(a);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
}
// Draw the chords
for (let i = 0; i < N; i++) {
const a1 = (i / N) * Math.PI * 2;
const a2 = ((i * multiplier) % N) / N * Math.PI * 2;
const x1 = cx + R * Math.cos(a1);
const y1 = cy + R * Math.sin(a1);
const x2 = cx + R * Math.cos(a2);
const y2 = cy + R * Math.sin(a2);
const hue = (i / N) * 360;
ctx.strokeStyle = 'hsla(' + hue + ', 80%, 60%, 0.5)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
// Label
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText('Cardioid: connect n to 2n', 20, 30);
ctx.fillText(N + ' points, multiplier = ' + multiplier, 20, 50);
</script>
Try changing the multiplier to 3 (nephroid), 4 (three-cusped shape), or non-integers like 2.5 for wild asymmetric curves. Every integer multiplier k produces an epicycloid with k−1 cusps.
2. Parabolic string curves: two-line envelopes
Before the circle trick existed, Mary Everest Boole taught this simpler version. Take two straight lines that meet at a corner. Place evenly spaced points along each line. Connect point 1 on line A to point N on line B, point 2 to N−1, point 3 to N−2, and so on. The resulting strings trace a parabola in the corner. The proof is elegant: each string is tangent to the parabola at exactly one point, making the family of lines the parabola’s tangent envelope.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
const N = 40;
const margin = 80;
const corners = [
{ ax: margin, ay: H - margin, bx: W - margin, by: H - margin, cx: margin, cy: margin },
{ ax: W - margin, ay: H - margin, bx: W - margin, by: margin, cx: margin, cy: H - margin },
{ ax: W - margin, ay: margin, bx: margin, by: margin, cx: W - margin, cy: H - margin },
{ ax: margin, ay: margin, bx: margin, by: H - margin, cx: W - margin, cy: margin },
];
const colors = ['#ff6b6b', '#4ecdc4', '#ffe66d', '#a29bfe'];
corners.forEach(function(corner, ci) {
for (let i = 0; i <= N; i++) {
const t1 = i / N;
const t2 = 1 - i / N;
// Point on line from corner vertex (ax,ay) to end of arm 1 (bx,by)
const x1 = corner.ax + t1 * (corner.bx - corner.ax);
const y1 = corner.ay + t1 * (corner.by - corner.ay);
// Point on line from corner vertex (ax,ay) to end of arm 2 (cx,cy)
const x2 = corner.ax + t2 * (corner.cx - corner.ax);
const y2 = corner.ay + t2 * (corner.cy - corner.ay);
ctx.strokeStyle = colors[ci] + '88';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
});
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText('Parabolic string curves (4 corners)', 20, 30);
</script>
Four parabolic envelopes meeting at the corners of a square create a shape that looks like a curved diamond. Each individual line is straight, but together they describe four smooth parabolic arcs.
3. Circular string pattern: modular multiplication revisited
The cardioid example used a fixed multiplier. This version animates it. As the multiplier sweeps continuously from 2.0 to 50.0, the string art pattern morphs through dozens of different shapes. Some multipliers produce clean symmetric curves. Others produce tangled webs. The transitions between them are mesmerising.
This is the famous "times tables on a circle" visualisation that Mathologer popularised. Each frame redraws all the chords for the current multiplier value.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const cx = W / 2, cy = H / 2, R = 350;
const N = 300;
let mult = 2.0;
function draw() {
ctx.fillStyle = 'rgba(10, 10, 18, 0.15)';
ctx.fillRect(0, 0, W, H);
// Points on circle
for (let i = 0; i < N; i++) {
const a1 = (i / N) * Math.PI * 2 - Math.PI / 2;
const target = (i * mult) % N;
const a2 = (target / N) * Math.PI * 2 - Math.PI / 2;
const x1 = cx + R * Math.cos(a1);
const y1 = cy + R * Math.sin(a1);
const x2 = cx + R * Math.cos(a2);
const y2 = cy + R * Math.sin(a2);
const hue = (i / N) * 300 + mult * 10;
ctx.strokeStyle = 'hsla(' + (hue % 360) + ', 70%, 55%, 0.3)';
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
ctx.fillStyle = '#fff';
ctx.font = '16px monospace';
ctx.fillText('Multiplier: ' + mult.toFixed(2), 20, 30);
mult += 0.005;
if (mult > 51) mult = 2.0;
requestAnimationFrame(draw);
}
draw();
</script>
Watch for multiplier 2 (cardioid), 3 (nephroid), 33 (star burst), 34 (fine web), and 51 (back to a near-circle). The fractional values between integers produce the most unpredictable shapes.
4. Multi-layer string art design
Real string art pieces often combine multiple patterns in different colours on the same board. This example layers three techniques: a parabolic corner set, a cardioid ring, and a set of radiating lines from centre to circumference. The layers use different colour palettes and line weights so they read as distinct elements but form a cohesive composition.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const cx = W / 2, cy = H / 2;
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
// Layer 1: Parabolic corner curves (outer frame)
const margin = 40;
const pts = 30;
const cornerPairs = [
[[margin, margin], [W - margin, margin], [margin, H - margin]],
[[W - margin, margin], [W - margin, H - margin], [margin, margin]],
[[W - margin, H - margin], [margin, H - margin], [W - margin, margin]],
[[margin, H - margin], [margin, margin], [W - margin, H - margin]],
];
cornerPairs.forEach(function(pair) {
const v = pair[0], a = pair[1], b = pair[2];
for (let i = 0; i <= pts; i++) {
const t = i / pts;
const x1 = v[0] + t * (a[0] - v[0]);
const y1 = v[1] + t * (a[1] - v[1]);
const x2 = v[0] + (1 - t) * (b[0] - v[0]);
const y2 = v[1] + (1 - t) * (b[1] - v[1]);
ctx.strokeStyle = 'rgba(255, 107, 107, 0.35)';
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
});
// Layer 2: Cardioid (middle ring)
const R2 = 250;
const N2 = 180;
for (let i = 0; i < N2; i++) {
const a1 = (i / N2) * Math.PI * 2;
const a2 = ((i * 2) % N2) / N2 * Math.PI * 2;
ctx.strokeStyle = 'rgba(78, 205, 196, 0.4)';
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(cx + R2 * Math.cos(a1), cy + R2 * Math.sin(a1));
ctx.lineTo(cx + R2 * Math.cos(a2), cy + R2 * Math.sin(a2));
ctx.stroke();
}
// Layer 3: Radiating star (inner)
const R3 = 120;
const spokes = 60;
for (let i = 0; i < spokes; i++) {
for (let j = i + 1; j < Math.min(i + 8, spokes); j++) {
const a1 = (i / spokes) * Math.PI * 2;
const a2 = (j / spokes) * Math.PI * 2;
ctx.strokeStyle = 'rgba(255, 230, 109, 0.25)';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(cx + R3 * Math.cos(a1), cy + R3 * Math.sin(a1));
ctx.lineTo(cx + R3 * Math.cos(a2), cy + R3 * Math.sin(a2));
ctx.stroke();
}
}
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText('Multi-layer string art', 20, 30);
</script>
The three layers use red, teal, and gold. Each layer reads independently but together they create depth. Real nail-and-thread artists use exactly this principle—overlapping string patterns in complementary colours on a dark background.
5. Epicycloid string figures: beyond the cardioid
The cardioid is just the k = 2 case. Different multipliers produce different epicycloids, and you can blend them by drawing multiple multiplier values simultaneously. This example draws five epicycloids on the same circle, each in a different colour, creating an interference pattern where the curves overlap.
The maths: connecting point n to point kn on a circle produces an epicycloid with k−1 cusps. So k = 2 gives 1 cusp (cardioid), k = 3 gives 2 cusps (nephroid), k = 4 gives 3 cusps, and so on.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const cx = W / 2, cy = H / 2, R = 350;
const N = 360;
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
const layers = [
{ k: 2, color: 'rgba(255, 100, 100, 0.35)', width: 0.8 },
{ k: 3, color: 'rgba(100, 200, 255, 0.30)', width: 0.7 },
{ k: 5, color: 'rgba(180, 100, 255, 0.25)', width: 0.6 },
{ k: 7, color: 'rgba(100, 255, 150, 0.20)', width: 0.5 },
{ k: 11, color: 'rgba(255, 220, 100, 0.18)', width: 0.5 },
];
layers.forEach(function(layer) {
for (let i = 0; i < N; i++) {
const a1 = (i / N) * Math.PI * 2;
const a2 = ((i * layer.k) % N) / N * Math.PI * 2;
const x1 = cx + R * Math.cos(a1);
const y1 = cy + R * Math.sin(a1);
const x2 = cx + R * Math.cos(a2);
const y2 = cy + R * Math.sin(a2);
ctx.strokeStyle = layer.color;
ctx.lineWidth = layer.width;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
});
// Labels
ctx.fillStyle = '#fff';
ctx.font = '13px monospace';
ctx.fillText('Epicycloid string figures', 20, 30);
var labels = ['k=2 cardioid', 'k=3 nephroid', 'k=5 quatrefoil', 'k=7 sexfoil', 'k=11 decafoil'];
var lColors = ['#ff6464', '#64c8ff', '#b464ff', '#64ff96', '#ffdc64'];
for (var li = 0; li < labels.length; li++) {
ctx.fillStyle = lColors[li];
ctx.fillText(labels[li], 20, 55 + li * 20);
}
</script>
Prime number multipliers tend to produce the most complex patterns because they don’t share factors with the number of points. Composite multipliers create simpler shapes with obvious symmetry axes.
6. Interactive string sculptor
This version lets you sculpt string art patterns in real time. Move your mouse to change the multiplier (horizontal position) and the number of connection points (vertical position). You get instant visual feedback as the curve morphs continuously. Click to freeze a configuration.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const cx = W / 2, cy = H / 2, R = 340;
let mult = 2.0;
let N = 200;
let frozen = false;
c.addEventListener('mousemove', function(e) {
if (frozen) return;
const rect = c.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
mult = 2 + mx * 48;
N = Math.floor(50 + my * 450);
});
c.addEventListener('click', function() {
frozen = !frozen;
});
function draw() {
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
// Outer ring
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < N; i++) {
const a1 = (i / N) * Math.PI * 2 - Math.PI / 2;
const target = (i * mult) % N;
const a2 = (target / N) * Math.PI * 2 - Math.PI / 2;
const x1 = cx + R * Math.cos(a1);
const y1 = cy + R * Math.sin(a1);
const x2 = cx + R * Math.cos(a2);
const y2 = cy + R * Math.sin(a2);
const hue = (i / N) * 360;
ctx.strokeStyle = 'hsla(' + (hue % 360) + ', 75%, 55%, 0.35)';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText('Multiplier: ' + mult.toFixed(2) + ' Points: ' + N, 20, 30);
ctx.fillText(frozen ? 'FROZEN (click to unfreeze)' : 'Move mouse to sculpt, click to freeze', 20, 50);
requestAnimationFrame(draw);
}
draw();
</script>
Move slowly. The most interesting shapes appear at non-integer multiplier values—especially near integers. The region around 33.0 to 34.0 is particularly rich, where the pattern oscillates between star bursts and fine webs.
7. 3D string surface: hyperboloid of one sheet
Here is something physical string art cannot easily do: rotation. A hyperboloid of one sheet is a 3D surface that can be built entirely from straight lines. Cooling towers at power plants use this shape because straight steel beams can form a curved surface, saving construction costs. Two families of straight lines cross each other on the surface, creating a lattice that defines the double-curved shape.
This example draws a rotating wireframe hyperboloid using only straight lines. No 3D library—just perspective projection and basic trigonometry.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const cx = W / 2, cy = H / 2;
const nLines = 40;
const segments = 60;
const waist = 100;
const topR = 200;
const height = 500;
let angle = 0;
function project(x, y, z) {
const d = 900;
const scale = d / (d + z);
return [cx + x * scale, cy + y * scale, scale];
}
function draw() {
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
// Draw two families of ruling lines
for (let family = 0; family < 2; family++) {
const sign = family === 0 ? 1 : -1;
for (let i = 0; i < nLines; i++) {
const theta = (i / nLines) * Math.PI * 2;
ctx.beginPath();
let first = true;
for (let s = 0; s <= segments; s++) {
const t = (s / segments) * 2 - 1; // -1 to 1
const r = Math.sqrt(waist * waist + (topR - waist) * (topR - waist) * t * t);
const phi = theta + sign * Math.atan(t * (topR - waist) / waist);
let x = r * Math.cos(phi);
let z = r * Math.sin(phi);
const y = t * height / 2;
// Rotate around Y axis
const rx = x * cosA - z * sinA;
const rz = x * sinA + z * cosA;
const p = project(rx, y, rz);
if (first) { ctx.moveTo(p[0], p[1]); first = false; }
else ctx.lineTo(p[0], p[1]);
}
const hue = family === 0 ? 200 + (i / nLines) * 60 : 20 + (i / nLines) * 40;
ctx.strokeStyle = 'hsla(' + hue + ', 70%, 55%, 0.4)';
ctx.lineWidth = 0.8;
ctx.stroke();
}
}
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText('Hyperboloid of one sheet (ruled surface)', 20, 30);
ctx.fillText('Built entirely from straight lines', 20, 50);
angle += 0.008;
requestAnimationFrame(draw);
}
draw();
</script>
The hyperboloid is a "ruled surface"—every point on it lies on a straight line that stays entirely on the surface. Two families of lines crisscross, and their intersections trace out the curved shape. Architects love these structures: the Kobe Port Tower, the Canton Tower in Guangzhou, and countless cooling towers all use this principle.
8. Generative string art composition
The final example combines everything: multiple circles with different multipliers, parabolic corner frames, animated rotation, and a colour scheme that shifts over time. Each frame draws new lines with low opacity, so the composition builds up gradually like a long-exposure photograph. Click to reset and generate a new random configuration.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const cx = W / 2, cy = H / 2;
let seed = Date.now();
function rand() {
seed = (seed * 16807 + 0) % 2147483647;
return (seed - 1) / 2147483646;
}
let config;
function newConfig() {
config = {
rings: [],
hueBase: rand() * 360,
cornerLines: Math.floor(20 + rand() * 30),
frameCount: 0,
};
const nRings = 2 + Math.floor(rand() * 3);
for (let i = 0; i < nRings; i++) {
config.rings.push({
R: 100 + rand() * 250,
N: Math.floor(100 + rand() * 300),
k: 2 + rand() * 20,
kSpeed: (rand() - 0.5) * 0.01,
hueOff: rand() * 120,
alpha: 0.08 + rand() * 0.15,
});
}
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
}
newConfig();
c.addEventListener('click', newConfig);
function draw() {
// Slow fade for trail effect
ctx.fillStyle = 'rgba(10, 10, 18, 0.02)';
ctx.fillRect(0, 0, W, H);
config.frameCount++;
const t = config.frameCount;
// Draw corner parabolas (static, low alpha)
if (t === 1) {
const m = 30;
const n = config.cornerLines;
const cs = [[m, m, W - m, m, m, H - m], [W - m, m, W - m, H - m, m, m],
[W - m, H - m, m, H - m, W - m, m], [m, H - m, m, m, W - m, H - m]];
cs.forEach(function(p, ci) {
for (let i = 0; i <= n; i++) {
const s = i / n;
const x1 = p[0] + s * (p[2] - p[0]);
const y1 = p[1] + s * (p[3] - p[1]);
const x2 = p[0] + (1 - s) * (p[4] - p[0]);
const y2 = p[1] + (1 - s) * (p[5] - p[1]);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
});
}
// Animate ring multipliers and draw chords
config.rings.forEach(function(ring) {
ring.k += ring.kSpeed;
const hue = (config.hueBase + ring.hueOff + t * 0.2) % 360;
for (let i = 0; i < ring.N; i++) {
const a1 = (i / ring.N) * Math.PI * 2;
const a2 = ((i * ring.k) % ring.N) / ring.N * Math.PI * 2;
const x1 = cx + ring.R * Math.cos(a1);
const y1 = cy + ring.R * Math.sin(a1);
const x2 = cx + ring.R * Math.cos(a2);
const y2 = cy + ring.R * Math.sin(a2);
ctx.strokeStyle = 'hsla(' + hue + ', 65%, 55%, ' + ring.alpha + ')';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
});
requestAnimationFrame(draw);
}
draw();
</script>
Each click generates a new configuration with random radii, multipliers, drift speeds, and colours. Let it run for 30 seconds and the trails build into dense, luminous compositions. The slow fade means older lines gradually disappear, so the pattern is always evolving.
Where to go from here
- Nail and thread simulation — render the "nails" as metallic circles and the "thread" with slight sag using quadratic Bézier curves. Add a wood-grain background texture. The result looks like a photograph of real string art.
- Polar string patterns — instead of connecting points on a circle, connect points on a polar curve like a rose or a lemniscate. The intersection of string art with polar geometry produces shapes impossible to create with physical nails and thread.
- Colour gradient threading — assign a colour to each nail and interpolate along the thread. HSL interpolation along the string creates smooth rainbow effects. Real craft string art often uses gradient thread for exactly this reason.
- Sound-reactive strings — drive the multiplier from a Web Audio analyser frequency bin. Bass frequencies control the main cardioid multiplier; treble controls the number of connection points. The pattern pulses and morphs with the music.
- Export to SVG — because string art is purely lines, it exports perfectly to SVG for laser cutting, pen plotting, or large-format printing. Build an export button that writes each line as an SVG path element.
Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For more line-based techniques, try the Bézier curves guide, the math art guide, or the geometric art guide.