AI Spirograph Generator - Geometric Pattern Art Create intricate hypotrochoid patterns with adjusta

This sketch is an interactive digital spirograph that draws hypotrochoid curves - the looping geometric patterns you'd get from a mechanical Spirograph toy. Three sliders let you control the outer wheel radius (R), inner wheel radius (r), and pen offset (d), and the resulting curve is rendered as a full rainbow gradient on a dark background.

🧪 Try This!

Experiment with the code by making these changes:

  1. Darken or recolor the background — The background() call's brightness and hue values set the canvas's backdrop color - try a deep blue instead of near-black.
  2. Thicken the curve lines — strokeWeight() controls how thick every line segment is drawn, making the whole pattern look bolder.
  3. Reverse the rainbow direction — Swapping the hue mapping's start and end values flips which end of the color wheel the curve starts and ends on.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch recreates the classic mechanical Spirograph toy entirely in code, drawing intricate looping curves called hypotrochoids from a simple parametric equation. Three live sliders control the outer radius R, inner radius r, and pen distance d, and every change instantly redraws a new pattern colored with a full HSB rainbow gradient. It's a great showcase of parametric math, DOM-based UI controls, and HSB color mode all working together in one file.

The code is organized around a setup() that builds the canvas and slider UI once, and a draw() loop that reads the current slider values and redraws the entire curve every frame using the hypotrochoid formula. A small math helper, gcd(), figures out how many times the pattern must loop before it closes perfectly, and a spiroPoint() helper turns an angle into an (x, y) coordinate. Studying this sketch teaches you how to build simple UI controls with createSlider(), how parametric equations generate curves point-by-point, and how HSB color mode makes rainbow gradients easy.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, switches to HSB color mode, and calls createUI() to build three labeled sliders (Outer R, Inner r, Pen d) with sensible default values based on the window size
  2. Every frame, draw() clears the background and reads the current numeric values straight from the three sliders
  3. It computes how many times the curve must loop around before it closes exactly, using the greatest common divisor of r and (R - r), then works out how many line segments (steps) are needed to draw it smoothly
  4. A for loop walks through that angle range in tiny increments, calling spiroPoint() twice per iteration to get two nearby points on the curve, then draws a short line segment between them
  5. Each segment's color is set by mapping its position in the loop to a hue from 0 to 360, which paints the whole curve in a continuous rainbow using stroke() in HSB mode
  6. If the browser window is resized, windowResized() resizes the canvas and updates the sliders' maximum values so they stay proportional to the new window size

🎓 Concepts You'll Learn

Parametric equations (hypotrochoid curves)HSB color mode and rainbow gradientsDOM UI elements (createSlider, createDiv, createSpan)Greatest common divisor algorithmpush()/pop() and translate() transformsmap() for range conversionResponsive canvas with windowResized()

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it's used to configure the color system and build the DOM-based slider UI before any drawing happens in draw().

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100);
  smooth();          // ensure anti-aliased drawing
  noFill();
  frameRate(60);

  createUI();
}
Line-by-line explanation (6 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window so the spirograph has as much room as possible to draw.
colorMode(HSB, 360, 100, 100, 100);
Switches p5's color system to Hue-Saturation-Brightness with hue ranging 0-360, which makes it easy to sweep through every color of the rainbow just by changing a single hue number.
smooth();
Turns on anti-aliasing so curved lines look smooth instead of jagged.
noFill();
Ensures no shapes get an interior fill color - since this sketch only draws lines, this avoids unwanted fill artifacts.
frameRate(60);
Caps the sketch at 60 frames per second, keeping the redraw loop smooth and consistent.
createUI();
Calls the custom helper function that builds the slider panel in the top-left corner of the page.

createUI()

This function demonstrates p5's DOM API - createDiv(), createElement(), and .parent() - for building HTML-based user interfaces that live alongside the canvas.

function createUI() {
  controlsDiv = createDiv();
  controlsDiv.id("controls");

  const title = createElement("h1", "Spirograph");
  title.parent(controlsDiv);

  // Reasonable defaults based on window size
  const maxR = floor(min(width, height) / 2) - 40;
  const initialR = max(80, floor(maxR * 0.6));
  const initialr = floor(initialR * 0.45);
  const initiald = floor(initialr * 0.75);

  // Outer radius R
  ({ slider: RSlider, valueSpan: RValSpan } = createLabeledSlider(
    "Outer R",
    40,
    maxR,
    initialR,
    1
  ));

  // Inner radius r
  ({ slider: rSlider, valueSpan: rValSpan } = createLabeledSlider(
    "Inner r",
    10,
    maxR - 10,
    initialr,
    1
  ));

  // Pen distance d
  ({ slider: dSlider, valueSpan: dValSpan } = createLabeledSlider(
    "Pen d",
    1,
    maxR,
    initiald,
    1
  ));
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

calculation Max Radius Calculation const maxR = floor(min(width, height) / 2) - 40;

Figures out the largest sensible outer radius that will still fit inside the canvas with some margin.

calculation Default Slider Values const initialR = max(80, floor(maxR * 0.6));

Derives sensible starting values for R, r, and d so the pattern looks good the moment the page loads.

controlsDiv = createDiv();
Creates a new empty HTML <div> element to hold all the slider controls, storing a reference in the global controlsDiv variable.
controlsDiv.id("controls");
Gives the div the CSS id 'controls' so style.css can position and style it as a floating panel.
const title = createElement("h1", "Spirograph");
Creates an <h1> heading element with the text 'Spirograph' to label the panel.
title.parent(controlsDiv);
Nests the heading inside the controls div so it appears at the top of the panel.
const maxR = floor(min(width, height) / 2) - 40;
Calculates the biggest radius that fits inside the canvas, leaving a 40 pixel margin, and using whichever of width/height is smaller so it works on any screen shape.
const initialR = max(80, floor(maxR * 0.6));
Sets a default outer radius at 60% of the max, but never smaller than 80 pixels, so the pattern always has a reasonable starting size.
const initialr = floor(initialR * 0.45);
Sets the default inner radius to 45% of the outer radius, a ratio that tends to produce visually pleasing petal counts.
const initiald = floor(initialr * 0.75);
Sets the default pen distance to 75% of the inner radius, giving nicely rounded loops rather than sharp points or flat circles.
({ slider: RSlider, valueSpan: RValSpan } = createLabeledSlider(
Calls the helper function to build a labeled slider row for R, then destructures the returned object to store the slider and its value-display span in global variables.

createLabeledSlider()

This helper avoids repeating the same five lines of DOM setup three times - a good example of extracting reusable UI-building code into its own function.

function createLabeledSlider(labelText, min, max, initial, step) {
  const row = createDiv();
  row.addClass("control-row");
  row.parent(controlsDiv);

  const label = createSpan(labelText);
  label.addClass("label");
  label.parent(row);

  const valueSpan = createSpan(String(initial));
  valueSpan.addClass("value");
  valueSpan.parent(row);

  const slider = createSlider(min, max, initial, step);
  slider.parent(row);
  slider.input(() => {
    valueSpan.html(slider.value());
  });

  return { slider, valueSpan };
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Live Value Update slider.input(() => { valueSpan.html(slider.value()); });

Runs every time the user drags the slider, keeping the displayed number in sync with the slider's current position.

const row = createDiv();
Creates a container div for one label + slider + value row.
row.addClass("control-row");
Tags the row with a CSS class so style.css can space it out nicely.
const label = createSpan(labelText);
Creates a text span showing the slider's name, like 'Outer R'.
const valueSpan = createSpan(String(initial));
Creates a span that displays the current numeric value, starting with the initial value converted to a string.
const slider = createSlider(min, max, initial, step);
Creates an actual HTML range input with the given minimum, maximum, starting value, and step size.
slider.input(() => { valueSpan.html(slider.value()); });
Registers a callback that fires on every slider drag, updating the on-screen number to match the slider's live value.
return { slider, valueSpan };
Sends both the slider object and its value-display span back to the caller so they can be stored and read later in draw().

draw()

draw() runs 60 times per second and is where the actual hypotrochoid math happens. Because the whole curve is recalculated and redrawn from scratch every frame, dragging a slider instantly reshapes the entire pattern - a simple but powerful way to build real-time interactive visualizations.

🔬 This maps each segment's position in the loop to a hue between 0 and 360, painting a full rainbow along the curve. What happens if you change the range to 0, 720 so the hue wraps around twice?

    // Rainbow along the path: hue wraps from 0..360
    const hue = map(i, 0, steps, 0, 360);
    stroke(hue, 90, 100, 100);

🔬 This uses the greatest common divisor to calculate exactly how many loops are needed to close the curve. What happens if you force loops to always be 1, skipping the gcd calculation entirely?

  if (diff > 0) {
    const g = gcd(rint, diff);
    loops = rint / g;
  }
function draw() {
  // Dark, slightly non‑black background
  background(0, 0, 5, 100);

  // Read parameters from sliders
  const R = Number(RSlider.value());
  let r = Number(rSlider.value());
  const d = Number(dSlider.value());

  // Keep r positive and smaller than R to avoid division problems
  if (r < 1) r = 1;
  if (r >= R) r = R - 1;

  // Update value spans in case we clamped r
  rValSpan.html(nfc(r, 0));

  // Compute angle range for a closed hypotrochoid:
  // tMax = 2π * r / gcd(r, |R - r|)
  const Rint = max(1, round(R));
  const rint = max(1, round(r));
  const diff = abs(Rint - rint);
  let loops = 1;
  if (diff > 0) {
    const g = gcd(rint, diff);
    loops = rint / g;
  }
  const tMax = TWO_PI * loops;

  // Resolution: more loops → more steps
  let steps = floor(loops * 800);
  steps = constrain(steps, 1500, 12000);

  push();
  translate(width / 2, height / 2);
  strokeWeight(2);

  for (let i = 0; i < steps; i++) {
    const t1 = map(i, 0, steps, 0, tMax);
    const t2 = map(i + 1, 0, steps, 0, tMax);

    const p1 = spiroPoint(t1, R, r, d);
    const p2 = spiroPoint(t2, R, r, d);

    // Rainbow along the path: hue wraps from 0..360
    const hue = map(i, 0, steps, 0, 360);
    stroke(hue, 90, 100, 100);

    line(p1.x, p1.y, p2.x, p2.y);
  }

  pop();
}
Line-by-line explanation (23 lines)

🔧 Subcomponents:

conditional Prevent Zero Inner Radius if (r < 1) r = 1;

Stops r from ever hitting zero, which would cause a division-by-zero in the hypotrochoid formula.

conditional Prevent Inner Radius Exceeding Outer if (r >= R) r = R - 1;

Keeps r smaller than R so the (R - r) term never goes negative or zero in an unintended way.

conditional Loop Count Calculation if (diff > 0) { const g = gcd(rint, diff); loops = rint / g; }

Uses the greatest common divisor to figure out exactly how many revolutions are needed before the hypotrochoid pattern closes perfectly into a loop.

for-loop Draw the Curve for (let i = 0; i < steps; i++) { ... }

Steps through the full angle range in tiny increments, computing two nearby points each time and connecting them with a colored line segment to build up the entire curve.

background(0, 0, 5, 100);
Repaints the whole canvas with a near-black color (5% brightness in HSB) every frame so the previous frame's curve is fully erased before the new one is drawn.
const R = Number(RSlider.value());
Reads the outer radius slider's current position and converts it to a number.
let r = Number(rSlider.value());
Reads the inner radius slider's value; declared with let because it may get clamped below.
const d = Number(dSlider.value());
Reads the pen distance slider's value, which controls how far the drawing point sits from the center of the inner wheel.
if (r < 1) r = 1;
Guards against r being zero or negative, which would break the (R - r) / r calculation in spiroPoint().
if (r >= R) r = R - 1;
Guards against r being equal to or bigger than R, keeping the geometry meaningful (inner wheel smaller than outer wheel).
rValSpan.html(nfc(r, 0));
Updates the on-screen number for r in case the clamping above changed its value, using nfc() to format it as a plain number.
const Rint = max(1, round(R));
Rounds R to a whole number (radii need to be integers for the gcd math to make sense) and ensures it's at least 1.
const rint = max(1, round(r));
Same rounding and safety clamp applied to r.
const diff = abs(Rint - rint);
Computes the absolute difference between the two radii, which is used to find how many loops the curve needs.
let loops = 1;
Defaults to a single loop in case diff is zero (R equals r).
const g = gcd(rint, diff);
Finds the greatest common divisor of r and the radius difference, which determines the repeat period of the pattern.
loops = rint / g;
Divides r by the gcd to get exactly how many full revolutions are needed before the curve closes on itself without gaps or overlaps.
const tMax = TWO_PI * loops;
Converts the loop count into a total angle in radians - each loop is one full 2π revolution.
let steps = floor(loops * 800);
Decides how many tiny straight line segments will approximate the curve - more loops need more segments to stay smooth.
steps = constrain(steps, 1500, 12000);
Clamps the segment count to a sensible range so the sketch never draws too few (choppy) or too many (slow) segments.
translate(width / 2, height / 2);
Moves the drawing origin to the center of the canvas so the spirograph is centered on screen.
strokeWeight(2);
Sets the thickness of the lines used to draw the curve.
const t1 = map(i, 0, steps, 0, tMax);
Converts the current loop index into an angle t, scaling the range 0..steps to 0..tMax.
const p1 = spiroPoint(t1, R, r, d);
Uses the hypotrochoid formula to convert angle t1 into an actual (x, y) point on the curve.
const hue = map(i, 0, steps, 0, 360);
Maps the loop index to a hue value spanning the full color wheel, so the color smoothly shifts as the curve is traced.
stroke(hue, 90, 100, 100);
Sets the line color for this segment using the calculated hue with high saturation and brightness, in HSB mode.
line(p1.x, p1.y, p2.x, p2.y);
Draws a short straight line between two very close points on the curve; thousands of these tiny segments together look like a smooth curve.

spiroPoint()

This is the mathematical heart of the sketch - the classic hypotrochoid parametric equation. Every point on the curve is generated by plugging a different angle t into this same formula, which is a great example of how parametric equations trade an explicit shape description for a simple repeatable calculation.

🔬 The x equation adds the wobble term while the y equation subtracts it - that asymmetry is what makes hypotrochoids loop. What happens if you make the y equation add instead of subtract?

  const x = (R - r) * cos(t) + d * cos(k * t);
  const y = (R - r) * sin(t) - d * sin(k * t);
function spiroPoint(t, R, r, d) {
  const k = (R - r) / r;
  const x = (R - r) * cos(t) + d * cos(k * t);
  const y = (R - r) * sin(t) - d * sin(k * t);
  return { x, y };
}
Line-by-line explanation (4 lines)
const k = (R - r) / r;
Calculates the ratio between the difference of the radii and the inner radius - this controls how fast the pen wobble spins relative to the main revolution, determining the number of petals in the final pattern.
const x = (R - r) * cos(t) + d * cos(k * t);
The hypotrochoid x-coordinate formula: combines the position of the rolling circle's center with the pen's offset wobble, both driven by the current angle t.
const y = (R - r) * sin(t) - d * sin(k * t);
The matching y-coordinate formula - note the minus sign here (versus plus in x), which is what gives the hypotrochoid its characteristic looping shape instead of a simple ellipse.
return { x, y };
Packages both coordinates into a single object so the caller can use point.x and point.y.

gcd()

This is the classic Euclidean algorithm, one of the oldest algorithms in mathematics. Here it's used to figure out how many times the pen must revolve before the spirograph pattern closes perfectly instead of leaving a gap.

function gcd(a, b) {
  a = abs(a);
  b = abs(b);
  while (b !== 0) {
    const temp = b;
    b = a % b;
    a = temp;
  }
  return a;
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

while-loop Euclidean Algorithm Loop while (b !== 0) { const temp = b; b = a % b; a = temp; }

Repeatedly applies the modulo operation to shrink the two numbers down until the remainder is zero, at which point the other number is the greatest common divisor.

a = abs(a);
Makes sure a is treated as a positive number, since gcd doesn't care about sign.
b = abs(b);
Same safety step for b.
while (b !== 0) {
Keeps looping the Euclidean algorithm until b shrinks down to exactly zero.
const temp = b;
Temporarily saves the current value of b before it gets overwritten.
b = a % b;
The core step: b becomes the remainder of a divided by b, gradually shrinking the numbers toward their common divisor.
a = temp;
a takes on the old value of b, continuing the swap-and-shrink pattern of the algorithm.
return a;
Once the loop ends, a holds the greatest common divisor of the two original numbers.

windowResized()

windowResized() is a special p5.js function that's called automatically whenever the browser window is resized, letting you keep your canvas and UI responsive without any extra event-listener setup.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);

  // Update slider maxes based on new canvas size
  const maxR = floor(min(width, height) / 2) - 40;
  RSlider.attribute("max", maxR);
  rSlider.attribute("max", maxR - 10);
  dSlider.attribute("max", maxR);
}
Line-by-line explanation (5 lines)
resizeCanvas(windowWidth, windowHeight);
This built-in p5 function fires automatically whenever the browser window changes size, so we resize the canvas to match the new window dimensions.
const maxR = floor(min(width, height) / 2) - 40;
Recalculates the maximum sensible outer radius for the new canvas size, same formula used in createUI().
RSlider.attribute("max", maxR);
Updates the outer radius slider's max HTML attribute so it can't be dragged past what fits on screen.
rSlider.attribute("max", maxR - 10);
Updates the inner radius slider's max value similarly, keeping it slightly smaller than R's max.
dSlider.attribute("max", maxR);
Updates the pen distance slider's max value to match the new canvas size.

📦 Key Variables

RSlider object

Reference to the HTML slider element controlling the outer radius R, read every frame in draw().

let RSlider;
rSlider object

Reference to the HTML slider element controlling the inner radius r.

let rSlider;
dSlider object

Reference to the HTML slider element controlling the pen distance d.

let dSlider;
RValSpan object

Reference to the <span> element that displays the current numeric value of R next to its slider.

let RValSpan;
rValSpan object

Reference to the <span> that displays r's current value; also updated in draw() when r gets clamped.

let rValSpan;
dValSpan object

Reference to the <span> that displays d's current value.

let dValSpan;
controlsDiv object

The container <div> that holds the whole slider panel, used as the parent for every UI element created in createUI().

let controlsDiv;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG windowResized()

When the window shrinks, the sliders' max attributes are lowered, but their current values are never checked or clamped, so R, r, or d could remain set higher than the new max and produce an oddly cropped or off-center pattern.

💡 After updating each slider's max attribute, also check if its current value exceeds the new max and call slider.value(newMax) to clamp it.

PERFORMANCE draw()

The entire curve (up to 12000 line segments) is recalculated and redrawn from scratch every single frame, even when none of the sliders have changed since the last frame.

💡 Only recompute and redraw the curve when a slider's input event fires, caching the last drawn frame otherwise (e.g. draw once into a createGraphics() buffer and just display that buffer each frame until a slider changes).

STYLE draw()

Several derived variables (Rint, rint, diff, loops, tMax, steps) are computed inline inside draw(), making the function quite long and mixing UI-reading, math, and rendering concerns together.

💡 Extract the loop-count and step-count calculations into a small helper function like computeCurveResolution(R, r) that returns { loops, steps, tMax }, making draw() easier to read at a glance.

FEATURE draw() / spiroPoint()

There's currently no way to save or export the generated pattern as an image, even though the curve can look quite striking.

💡 Add a keyPressed() handler that calls saveCanvas('spirograph', 'png') when the user presses a key like 's', letting them download their favorite patterns.

🔄 Code Flow

Code flow showing setup, createui, createlabeledslider, draw, spiropoint, gcd, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> createui[createui] createui --> createlabeledslider[createlabeledslider] createlabeledslider --> draw[draw loop] draw --> maxr-calc[maxr-calc] draw --> initial-values-calc[initial-values-calc] draw --> slider-input-handler[slider-input-handler] draw --> clamp-r-low[clamp-r-low] draw --> clamp-r-high[clamp-r-high] draw --> loops-calc[loops-calc] draw --> draw-curve-loop[draw-curve-loop] draw --> spiropoint[spiropoint] draw --> gcd[gcd] draw --> windowresized[windowresized] draw-curve-loop -->|for loop| draw-curve-loop euclid-loop -->|while loop| euclid-loop click setup href "#fn-setup" click createui href "#fn-createui" click createlabeledslider href "#fn-createlabeledslider" click draw href "#fn-draw" click maxr-calc href "#sub-maxr-calc" click initial-values-calc href "#sub-initial-values-calc" click slider-input-handler href "#sub-slider-input-handler" click clamp-r-low href "#sub-clamp-r-low" click clamp-r-high href "#sub-clamp-r-high" click loops-calc href "#sub-loops-calc" click draw-curve-loop href "#sub-draw-curve-loop" click spiropoint href "#fn-spiropoint" click gcd href "#fn-gcd" click windowresized href "#fn-windowresized"

❓ Frequently Asked Questions

What type of visual patterns does the AI Spirograph Generator create?

The AI Spirograph Generator creates intricate hypotrochoid patterns, resulting in beautiful geometric designs with a rainbow gradient.

How can users customize their experience with the Spirograph sketch?

Users can interact with the sketch by adjusting sliders for the outer radius, inner radius, and pen distance to create unique designs.

What creative coding concepts are demonstrated in this p5.js sketch?

This sketch demonstrates the use of hypotrochoid equations for generating geometric patterns and interactive UI elements using p5.js.

Preview

AI Spirograph Generator - Geometric Pattern Art Create intricate hypotrochoid patterns with adjusta - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Spirograph Generator - Geometric Pattern Art Create intricate hypotrochoid patterns with adjusta - Code flow showing setup, createui, createlabeledslider, draw, spiropoint, gcd, windowresized
Code Flow Diagram