04Rainbow Gear Pattern Drawn by Several Pen Holes at Once
Several pen holes on the same gear each get their own color and separately computed coordinates, so one spin draws overlapping layers of the pattern with the colors shifted apart.
Best forColorful hero background pattern
PEN_RATIOSPEN_COLORSPEN_PHASE

<main class="stage">
<div class="sp4 is-demo" id="sp4Root" tabindex="0">
<canvas class="sp4__canvas" id="sp4Canvas" width="480" height="300"></canvas>
<p class="sp4__caption">Assigns a different color to each of several pen holes on the same gear and calculates each hole's coordinates separately, drawing several offset, overlapping layers of the pattern at once in a single rotation</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.sp4 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-paper;
}
.sp4__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp4__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp4__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sp4 { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp4 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffffff;
}
.sp4__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp4__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp4__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp4 {
cursor: default;
}
}(function () {
// 기법: 같은 링·톱니 조합에서 펜 구멍마다 반지름 비율(d)과 시작 위상을 달리해
// 좌표를 따로 계산한다. 위상은 seedRand(결정적 LCG) 로 고정해 매번 같은 그림이 나온다.
var root = document.getElementById('sp4Root');
var canvas = document.getElementById('sp4Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var CALC_DIM = 200;
var GEAR_RING_TEETH = 78;
var GEAR_ROLL_TEETH = 21;
var TOOTH_UNIT = 0.7;
var SEGMENTS_PER_TURN = 260;
var PEN_RATIOS = [0.35, 0.5, 0.65, 0.8];
var PEN_COLORS = ['rgba(230,90,120,.85)', 'rgba(50,175,135,.85)', 'rgba(70,130,230,.85)', 'rgba(228,150,45,.85)'];
function gcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b) { var t = b; b = a % b; a = t; } return a; }
var R = GEAR_RING_TEETH * TOOTH_UNIT;
var r = GEAR_ROLL_TEETH * TOOTH_UNIT;
var g = gcd(GEAR_RING_TEETH, GEAR_ROLL_TEETH);
var TURNS = GEAR_ROLL_TEETH / g;
var TOTAL_T = TURNS * Math.PI * 2;
var CYCLE_SECONDS = 7;
var SPEED = TOTAL_T / CYCLE_SECONDS;
var phaseRand = seedRand(43304);
var PEN_PHASE = PEN_RATIOS.map(function () { return phaseRand() * Math.PI * 2; });
function point(t, d) {
var k = (R - r) / r;
return {
x: (R - r) * Math.cos(t) + d * Math.cos(k * t),
y: (R - r) * Math.sin(t) - d * Math.sin(k * t)
};
}
var cw = 480, ch = 300, scale = 1, cx = 240, cy = 150;
function fit() {
var rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas.width = cw; canvas.height = ch;
scale = Math.min(cw, ch) / CALC_DIM * 0.92;
cx = cw / 2; cy = ch / 2;
}
function toScreen(p) { return { x: cx + p.x * scale, y: cy + p.y * scale }; }
function drawPen(idx, tMax) {
var d = r * PEN_RATIOS[idx];
var phase = PEN_PHASE[idx];
ctx.strokeStyle = PEN_COLORS[idx];
ctx.lineWidth = Math.max(2.4, scale * 0.032);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var steps = Math.max(2, Math.round((tMax / (Math.PI * 2)) * SEGMENTS_PER_TURN));
ctx.beginPath();
for (var i = 0; i <= steps; i += 1) {
var t = (i / steps) * tMax;
var s = toScreen(point(t + phase, d));
if (i === 0) ctx.moveTo(s.x, s.y); else ctx.lineTo(s.x, s.y);
}
ctx.stroke();
ctx.save();
ctx.fillStyle = PEN_COLORS[idx].replace('.85', '.13');
ctx.fill('evenodd');
ctx.restore();
if (tMax > 0.001) {
var tip = toScreen(point(tMax + phase, d));
ctx.fillStyle = PEN_COLORS[idx];
ctx.beginPath();
ctx.arc(tip.x, tip.y, Math.max(2.6, scale * 0.042), 0, Math.PI * 2);
ctx.fill();
}
}
function drawFull(tMax) {
ctx.clearRect(0, 0, cw, ch);
var tipW = toScreen(point(tMax + PEN_PHASE[0], r * PEN_RATIOS[0]));
var wash = ctx.createRadialGradient(tipW.x, tipW.y, 0, tipW.x, tipW.y, Math.max(cw, ch) * 0.9);
wash.addColorStop(0, 'rgba(255,200,215,.55)');
wash.addColorStop(0.5, 'rgba(190,225,255,.32)');
wash.addColorStop(1, 'rgba(200,240,215,.16)');
ctx.fillStyle = wash;
ctx.fillRect(0, 0, cw, ch);
for (var i = 0; i < PEN_RATIOS.length; i += 1) drawPen(i, tMax);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
fit();
if (reduce) {
drawFull(TOTAL_T);
} else {
var elapsed = 0.3;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
elapsed += dt / 1000;
var t = elapsed * SPEED;
if (t > TOTAL_T) { elapsed = 0; t = 0; }
drawFull(t);
requestAnimationFrame(tick);
}
drawFull(elapsed * SPEED);
window.addEventListener('resize', function () { fit(); drawFull(elapsed * SPEED); });
requestAnimationFrame(tick);
}
})();import { useEffect, useRef } from "react";
import styles from "./SpiroSceneMultiPenRainbow.module.scss";
type Pt = { x: number; y: number };
export default function SpiroSceneMultiPenRainbow() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const CALC_DIM = 200;
const GEAR_RING_TEETH = 78;
const GEAR_ROLL_TEETH = 21;
const TOOTH_UNIT = 0.7;
const SEGMENTS_PER_TURN = 260;
const PEN_RATIOS = [0.35, 0.5, 0.65, 0.8];
const PEN_COLORS = ["rgba(230,90,120,.85)", "rgba(50,175,135,.85)", "rgba(70,130,230,.85)", "rgba(228,150,45,.85)"];
function gcd(a: number, b: number): number {
a = Math.abs(a); b = Math.abs(b);
while (b) { const t = b; b = a % b; a = t; }
return a;
}
const R = GEAR_RING_TEETH * TOOTH_UNIT;
const r = GEAR_ROLL_TEETH * TOOTH_UNIT;
const g = gcd(GEAR_RING_TEETH, GEAR_ROLL_TEETH);
const TURNS = GEAR_ROLL_TEETH / g;
const TOTAL_T = TURNS * Math.PI * 2;
const CYCLE_SECONDS = 7;
const SPEED = TOTAL_T / CYCLE_SECONDS;
const phaseRand = seedRand(43304);
const PEN_PHASE = PEN_RATIOS.map(function () { return phaseRand() * Math.PI * 2; });
function point(t: number, d: number): Pt {
const k = (R - r) / r;
return {
x: (R - r) * Math.cos(t) + d * Math.cos(k * t),
y: (R - r) * Math.sin(t) - d * Math.sin(k * t)
};
}
let cw = 480, ch = 300, scale = 1, cx = 240, cy = 150;
function fit() {
const rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas!.width = cw; canvas!.height = ch;
scale = Math.min(cw, ch) / CALC_DIM * 0.92;
cx = cw / 2; cy = ch / 2;
}
function toScreen(p: Pt): Pt { return { x: cx + p.x * scale, y: cy + p.y * scale }; }
function drawPen(idx: number, tMax: number) {
const d = r * PEN_RATIOS[idx];
const phase = PEN_PHASE[idx];
ctx!.strokeStyle = PEN_COLORS[idx];
ctx!.lineWidth = Math.max(2.4, scale * 0.032);
ctx!.lineJoin = "round";
ctx!.lineCap = "round";
const steps = Math.max(2, Math.round((tMax / (Math.PI * 2)) * SEGMENTS_PER_TURN));
ctx!.beginPath();
for (let i = 0; i <= steps; i += 1) {
const t = (i / steps) * tMax;
const s = toScreen(point(t + phase, d));
if (i === 0) ctx!.moveTo(s.x, s.y); else ctx!.lineTo(s.x, s.y);
}
ctx!.stroke();
ctx!.save();
ctx!.fillStyle = PEN_COLORS[idx].replace('.85', '.13');
ctx!.fill('evenodd');
ctx!.restore();
if (tMax > 0.001) {
const tip = toScreen(point(tMax + phase, d));
ctx!.fillStyle = PEN_COLORS[idx];
ctx!.beginPath();
ctx!.arc(tip.x, tip.y, Math.max(2.6, scale * 0.042), 0, Math.PI * 2);
ctx!.fill();
}
}
function drawFull(tMax: number) {
ctx!.clearRect(0, 0, cw, ch);
const tipW = toScreen(point(tMax + PEN_PHASE[0], r * PEN_RATIOS[0]));
const wash = ctx!.createRadialGradient(tipW.x, tipW.y, 0, tipW.x, tipW.y, Math.max(cw, ch) * 0.9);
wash.addColorStop(0, 'rgba(255,200,215,.55)');
wash.addColorStop(0.5, 'rgba(190,225,255,.32)');
wash.addColorStop(1, 'rgba(200,240,215,.16)');
ctx!.fillStyle = wash;
ctx!.fillRect(0, 0, cw, ch);
for (let i = 0; i < PEN_RATIOS.length; i += 1) drawPen(i, tMax);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
fit();
let raf = 0;
if (reduce) {
drawFull(TOTAL_T);
} else {
let elapsed = 0.3;
let lastTs: number | null = null;
const onResize = () => { fit(); drawFull(elapsed * SPEED); };
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
elapsed += dt / 1000;
let t = elapsed * SPEED;
if (t > TOTAL_T) { elapsed = 0; t = 0; }
drawFull(t);
raf = requestAnimationFrame(tick);
}
drawFull(elapsed * SPEED);
window.addEventListener("resize", onResize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}
return () => { cancelAnimationFrame(raf); };
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sp4} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.sp4__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.sp4__caption}>같은 톱니의 펜 구멍 여러 개에 저마다 다른 색을 배정하고 각 구멍의 좌표를 따로 계산해, 한 번의 회전으로 색이 어긋나 겹치는 여러 겹의 무늬를 동시에 그린다</p>
</div>
</main>
);
}04 Rainbow Gear Pattern Drawn by Several Pen Holes at Once — Measured area 10.149% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Spirograph Generator: 9 Gear-Ratio Curve Patterns
The post this demo lives in
Spirograph Generator: 9 Gear-Ratio Curve PatternsWhen the two gear tooth counts share no common factor the curve closes, and the moment the ratio slips the sheet wanders See all demos in this post
If you like this effect - similar demos
Creative UI03 Storm sky — several strikes overlapping at random intervals9 Lightning Effect Background Scenes, No WebGL
Creative UI09 Hero-Scene Automaton Where Several Rule Values Run Side by Side in One FrameConway Game of Life in Canvas, 9 Patterns
Creative UI09 Hero Scene Where Several Values React TogetherVoronoi Diagram Generator — 9 Patterns