A browser-ready spirograph pattern generator demo UI + a cross-platform rendering library, shipped as an npm-workspaces monorepo.
Generate mathematically exact hypotrochoids & epitrochoids, animate them with rolling gears, and export high-resolution PNG / SVG — from the web, from React/Svelte, or straight from the CLI.
Play with the spirograph right now — no install needed:
https://leiwan5.github.io/spirograph/
Tweak the ring & rolling gears, stack multi-color pens, switch between inside / outside modes, hit play to watch the pen trace the pattern, then export a high-resolution PNG / SVG — all settings stay in the URL, so just copy the address bar to share what you made.
This is the static GitHub Pages deployment of this repo (see Deployment). The same app also runs locally with
npm run devonhttp://localhost:5173.
Each framework library ships its own self-contained demo page — install docs, live examples, and the exact same core math:
@spirograph/react — render-only + animated canvas components. ▶ Live demo · https://leiwan5.github.io/spirograph/react.html
import { SpirographCanvas } from '@spirograph/react';
<SpirographCanvas state={{ ringTeeth: 144, rollingTeeth: 60, pens: [{ hole: 40, color: '#e63946', width: 2.5 }] }} />@spirograph/svelte — Svelte 5 components, same core math. ▶ Live demo · https://leiwan5.github.io/spirograph/svelte.html
<script>
import { SpirographCanvas } from '@spirograph/svelte';
const state = { ringTeeth: 144, rollingTeeth: 60, pens: [{ hole: 40, color: '#e63946', width: 2.5 }] };
</script>
<SpirographCanvas {state} />@spirograph/react-native — React Native SVG components, with an embedded Expo Snack live demo you can run in the page or scan in Expo Go on your phone. ▶ Live demo · https://leiwan5.github.io/spirograph/react-native.html
- Try it live
- React & Svelte demo pages
- What it is
- Features
- Monorepo layout
- Quick start
- Web demo & URL parameters
- Using the library (
@spirograph/core) - React components (
@spirograph/react) - Svelte components (
@spirograph/svelte) - React Native components (
@spirograph/react-native) - CLI (
@spirograph/cli) - Image endpoint
- Deployment
- The math
- Development
- Contributing & license
A classic spirograph simulator: two gears mesh with a common tooth module — a fixed outer ring gear and a smaller rolling gear whose pen-hole traces a curve as it rolls. Depending on whether the rolling gear runs inside or outside the ring, you get a hypotrochoid or an epitrochoid, and the tooth counts determine how many lobes (petals) the finished figure has and when the loop closes.
Everything is computed from pure math in a shared core, so the exact same pattern renders identically on the <canvas> in your browser, inside a React, Svelte, or React Native component, as a generated SVG/PNG file, or through the CLI — with per-segment gradient colors resolved in one place to keep them consistent everywhere.
| Inside (hypotrochoid) | Outside (epitrochoid) |
|---|---|
![]() |
![]() |
- Gear specs — ring gear 40–240 teeth, rolling gear 8–96 teeth (with classic quick values), inside / outside modes.
-
Multi-pen stacking — any number of pens; each independently configured with hole (
0–100%of rolling radius), color, and width (0.5–8px). -
Two scale modes:
- Fixed image size — the pattern always fills the canvas.
- Fixed ring size — the gear ring keeps a constant on-screen size and the pattern is drawn at true scale inside (hole changes don't shift other pens).
-
Simulated drawing animation — the pen tip draws segment by segment; speed
0.1–10×, pausable/resumable, with an optional visible gear. -
Export — high-resolution PNG (
2048px) and SVG. - Presets & random — 7 classic gear combinations in one click, plus a random-inspiration button.
- URL sharing — every parameter travels through the querystring; copy the address bar to share the current pattern.
The React/Svelte libraries expose the same feature set as controllable, embeddable components.
The repo uses npm workspaces — the demo UI lives at the root, and each library is an independently publishable package:
packages/core/ @spirograph/core Pure core: math / geometry / gradients / segment render
contract / SVG / PNG — ZERO DOM or Node dependencies
packages/anim/ @spirograph/anim Optional animation driver (injectable frame scheduler)
packages/canvas/ @spirograph/canvas Browser-only Canvas 2D glue (renderer + export)
packages/react/ @spirograph/react React: <SpirographCanvas> (render-only) + <SpirographAnimated>
packages/svelte/ @spirograph/svelte Svelte 5: <SpirographCanvas> + <SpirographAnimated>
packages/react-native/
@spirograph/react-native React Native: SVG-based <SpirographSvg> + <SpirographAnimated>
on react-native-svg (shares the exact same core math)
apps/cli/ @spirograph/cli CLI: query / JSON → PNG / SVG files (bin: `spirograph`)
apps/expo-demo/ @spirograph/expo-demo Expo (React Native) demo app: interactive pattern controls
+ animation, powered by @spirograph/react-native
src/ api/ functions/ Web apps (root Vite multi-page): vanilla demo (index.html)
+ framework demo pages (svelte.html, react.html) + Vercel /
Cloudflare deployment config
Every renderer is a thin adapter over @spirograph/core, so any bug fix or new feature in the math is automatically shared across Canvas, React, Svelte, React Native, SVG, PNG, the image endpoint, and the CLI.
npm install
npm run dev # dev server http://localhost:5173
# vanilla demo at / React demo at /react.html
# Svelte demo at /svelte.html
npm test # build packages + Vitest unit tests (79 tests)
npm run build # build packages + typecheck + production multi-page build → dist/
npm run check:purity # guards the core library against platform dependencies
npm run build:cli # build the CLIThat's it — no backend required to run the demo. The image endpoint uses a small Vite middleware in dev, and optional serverless functions in production (see Deployment).
⚡ Already deployed: jump straight in at https://leiwan5.github.io/spirograph/ to try the app without running anything locally.
The vanilla demo (/) is a full editor: gear specs, per-pen controls, scale mode, animation speed, background color, presets, and export/download, all live-linked to the URL.
?ring=144&rolling=60&mode=inside&pen=40,1.8,3a86ff&pen=70,1.5,10,00bbf9,f4a261&bg=1b1b2f&speed=2.5&scale=fixed
| Param | Meaning | Range |
|---|---|---|
ring |
ring gear teeth | 40–240 |
rolling |
rolling gear teeth | 8–96 |
mode |
drawing mode |
inside / outside
|
pen |
one pen (repeatable): hole,width,color1 = solid; hole,width,spacing,color1[,color2[,color3[,color4]]] = multi-color gradient |
hole 0–100 / width 0.5–8 / spacing 1–100
|
bg |
background color | 6-digit hex (no #) |
speed |
animation speed | 0.1–10 |
scale |
scale mode |
auto / fixed
|
gears |
show the gear animation (maps to showGears) |
1 / 0
|
Gradient pens: 1 color = solid, ≥ 2 colors = gradient. The spacing parameter (a percentage) sets how far along the curve each color point sits before the set cycles — with ≥2 colors the pen fades smoothly between them around the closed loop.
Gears: the gears=1 URL param is the same showGears flag in SpirographState — it draws the ring + rolling gear during the animation (rotating with the active pen) and freezes them beneath the finished pattern. In the framework components it's simply state={{ ...DEFAULT_STATE, showGears: true }}.
Invalid parameters are silently ignored and fall back to defaults; when inside mode has rolling teeth ≥ ring teeth it is clamped automatically.
The core is framework- and platform-agnostic (browser, Node, React Native/Hermes, or serverless — zero DOM / Node dependencies, guarded by npm run check:purity). There are two entry points:
-
.(default) — pure logic: math, geometry, gradients, the segment render contract, and SVG/PNG generation. -
./browser— Canvas 2D rendering (a minimal structural interface, no DOM-lib dependency).
import { parseState, buildItems, buildSvg, generatePng, DEFAULT_STATE } from '@spirograph/core';
const state = parseState('?ring=72&rolling=30&pen=40,e63946,2.5');
const items = buildItems({ ...DEFAULT_STATE, ...state }); // merge parsed params over defaults
const svg = buildSvg(items, '#ffffff', 1024); // SVG string
const png = generatePng('?ring=72&rolling=30&pen=40,e63946,2.5'); // PNG bytes
// Browser Canvas rendering (./browser subpath)
import { renderFull, clearCanvas } from '@spirograph/core/browser';
const ctx = clearCanvas(canvas, 800, 800, '#ffffff', window.devicePixelRatio || 1);
renderFull(ctx, items, computeTransform(computeBounds(items.map(i => i.curve)), 800, 800, 32));Notable design points:
-
Unified colors — gradient colors are resolved centrally in
buildRenderData(per-segment), so Canvas, SVG, and PNG make identical color decisions.parseColoraccepts both hex andrgb(...)forms for rasterization. -
Retargetable render contract —
RenderData(segment-level data) pluscreateFramePlan(frame plan) andgeneratePng/generateSvgserializers are the consumed contracts, so a new backend only has to consumeRenderData. -
No URL API needed — URL encode/decode is a built-in pure string codec (no dependency on
URLSearchParams/TextEncoder).
Install: npm i @spirograph/react
Two components on top of the core:
-
<SpirographCanvas>— render-only: draw any pattern synchronously. -
<SpirographAnimated>— controllable animation with play/pause/resume and speed.
import { SpirographAnimated } from '@spirograph/react';
<SpirographAnimated
state={{ ringTeeth: 144, rollingTeeth: 60, pens: [{ hole: 40, color: '#e63946', width: 2.5 }] }}
speed={2}
className="w-full h-64"
/>Both accept a SpirographState/params object, expose imperative handles (SpirographHandle / SpirographAnimationHandle), and render to a <canvas> filled from the same core math. See the live docs + demos at /react.html.
Install: npm i @spirograph/svelte
The Svelte 5 component set mirrors React:
-
<SpirographCanvas>— render-only. -
<SpirographAnimated>— controllable animation.
<script>
import { SpirographAnimated } from '@spirograph/svelte';
const state = { ringTeeth: 144, rollingTeeth: 60, pens: [{ hole: 40, color: '#e63946', width: 2.5 }] };
</script>
<SpirographAnimated {state} />See the live docs + demos at /svelte.html.
Install: npm i @spirograph/react-native react-native-svg
A React Native adapter that renders patterns as SVG (via
react-native-svg)
instead of a browser <canvas>. It consumes @spirograph/core's segment render
data directly, so mobile output matches the web/Canvas/SVG/PNG output exactly:
-
<SpirographSvg>— render-only static view. -
<SpirographAnimated>— animated view withplay / pause / resume / stop / setSpeed(handled imperatively through a ref).
import { SpirographAnimated } from '@spirograph/react-native';
import type { SpirographAnimationHandle } from '@spirograph/react-native';
import { useRef } from 'react';
const ref = useRef<SpirographAnimationHandle>(null);
<SpirographAnimated
ref={ref}
state={{ mode: 'inside', ringTeeth: 72, rollingTeeth: 30,
pens: [{ id: 1, hole: 40, colors: ['#e63946'], spacing: 20, width: 4 }],
background: '#111827', speed: 1, scaleMode: 'auto', showGears: true }}
size={{ width: 320, height: 320 }}
showGears
/>;
ref.current?.play();There's a full Expo demo app at apps/expo-demo (@spirograph/expo-demo) with
interactive pattern/pen controls and playback — see its
README. The package ships the same core-math feature
set (inside/outside, multi-pen stacking, gears overlay, sequential/simultaneous
animation) in a React Native context.
Turn a URL-query (or JSON) directly into PNG / SVG files — the same query engine as the image endpoint, driven from the terminal.
npm install -g @spirograph/cli # or: npx @spirograph/cli
spirograph generate --params "ring=72&rolling=30&pen=40,e63946,2.5" --format png --size 2048 --out out.png
spirograph generate --params "ring=72&rolling=30&pen=40,2.5,10,e63946,00bbf9" --format svg --out out.svg
spirograph generate --json '{"ringTeeth":72,"rollingTeeth":30,"pens":[{"hole":40,"color":"#e63946","width":2.5}]}' --format png| Option | Description |
|---|---|
--params <query> |
URL query (same format as web share links / image endpoints) |
--json <json> |
SpirographState JSON (takes precedence over --params) |
--format <fmt> |
png / svg (default png) |
--size <n> |
PNG size 64–4096 (default 1000) |
--out <path> |
output path (default spirograph.<fmt>) |
$ spirograph generate --params "ring=72&rolling=30&pen=40,e63946,2.5&pen=75,2,1d6fa5" --format png --size 512
✓ generated PNG → spirograph.png (71231 bytes)
A URL with format=png or format=svg returns the image directly — usable in an <img> tag and saveable via right-click. Run the dev server (or deploy to Vercel/Cloudflare) and try:
http://localhost:5173/api/image?ring=72&rolling=30&pen=40,e63946,2.5&format=png&size=2048
http://localhost:5173/?ring=72&rolling=30&format=svg
- Parameters match the main app URL (
ring/rolling/mode/pen/bg/scale/speed, etc.), with extra support forsize(64–4096, default 1000). -
Development: Vite middleware (both
/?format=and/api/image). -
Production: serverless functions (Vercel
api/image.ts/ Cloudflare Pagesfunctions/api/image.ts). PNG encoding is pure JS (pako) — no native dependencies. - Everything is implemented by
@spirograph/core'sgenerateSvg/generatePng(query → image, the same source as the CLI).
- Push the repo to GitHub and import it in Vercel (Framework: Vite).
-
api/image.tsautomatically becomes the/api/imageendpoint; the static site is hosted as usual. - Image URL:
https://<your-domain>/api/image?...&format=png
- Build command
npm run build, output directorydist. - The
functions/directory automatically becomes Pages Functions; the/api/imageendpoint is enabled. - Image URL:
https://<your-domain>/api/image?...&format=png
GitHub Pages is pure static hosting and has no serverless functions, so the /api/image endpoint is unavailable: the frontend probes at runtime and automatically hides the Copy image link button; everything else works normally.
A .github/workflows/deploy.yml is included — on push to main it builds and deploys to a subpath:
- Repo Settings → Pages → Source → GitHub Actions.
- After pushing to
main, Actions buildsdist/and publishes (the first deploy requires a manualworkflow_dispatchtrigger). - Site URL:
https://<your-username>.github.io/<repo-name>/
Subpath resource references are injected via the build-time environment variable
BASE_URL(see the workflow), so renaming the repo requires no code changes. Localnpm run dev/npm run previewuse the relative path./by default and don't depend on that variable.
-
Inside (hypotrochoid):
x = (R−r)·cos t + d·cos((R−r)/r·t),y = (R−r)·sin t − d·sin((R−r)/r·t) -
Outside (epitrochoid):
x = (R+r)·cos t − d·cos((R+r)/r·t),y = (R+r)·sin t − d·sin((R+r)/r·t) - The two gears share the same module, so radii are proportional to tooth counts. The rolling gear returns to its start (the loop closes) after
T = 2π·qwithq = rolling teeth / gcd(ring, rolling)— which is exactly what determines the number of lobes.
npm install
npm run dev # multi-page dev server (vanilla / react / svelte demos)
npm test # build packages + Vitest unit tests
npm test -- packages/core # run only a package's tests
npm run build # packages + typecheck + production build → dist/
npm run check:purity # core purity guard (zero platform deps)
npm run build:cli # build the CLIThe Vite build is a multi-page app (build.rollupOptions.input): the original vanilla demo plus an independent docs/demo landing page per framework library:
-
/svelte.html— docs landing + live demo for@spirograph/svelte -
/react.html— docs landing + live demo for@spirograph/react -
/react-native.html— docs landing + embedded Expo Snack live demo for@spirograph/react-native
Each landing page shows render-only and animated usage, plus install/API docs. All four entries share the same base (GitHub Pages subpath via BASE_URL works for all of them).
Suggestions, bug reports, and PRs are welcome — see the GitHub repo issues. The project is MIT-licensed. Framework adapters (React, Svelte, and React Native via react-native-svg) and the CLI are all implemented on top of the same core RenderData contract.




