07Pattern Morphing Its Tooth Ratio by Pointer Distance
The tooth count is recalculated by how far the pointer sits from the center, so the pattern's petal count and density shift together with that value while the pointer moves.
Best forInteractive decorative background
POINTER_MAX_DISTROLL_TEETH_MINROLL_TEETH_MAX

<main class="stage">
<div class="sp7 is-demo" id="sp7Root" tabindex="0">
<canvas class="sp7__canvas" id="sp7Canvas" width="480" height="300"></canvas>
<p class="sp7__hint" id="sp7Hint">← →</p>
<p class="sp7__caption">Recalculates the tooth-count value by how far the pointer moves from the screen's center, so while the pointer moves, the pattern's petal count and density change together with that value</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.sp7 {
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-orange;
outline: none;
cursor: crosshair;
}
.sp7__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp7__hint {
position: absolute;
right: $sp-3;
top: $sp-3;
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;
}
.sp7__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) {
.sp7__caption, .sp7__hint { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sp7 { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp7 {
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: #ff4d1f;
outline: none;
cursor: crosshair;
}
.sp7__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp7__hint {
position: absolute;
right: 12px;
top: 12px;
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;
}
.sp7__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) {
.sp7__caption, .sp7__hint {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp7 {
cursor: default;
}
}(function () {
// 기법: 포인터·화살표 키가 공유하는 하나의 handlePointer(비율)로 톱니 수를 다시 계산해
// 패턴 전체를 즉시 다시 그린다. 자동 시연도 가상 좌표를 만들어 같은 함수를 부른다.
var root = document.getElementById('sp7Root');
var canvas = document.getElementById('sp7Canvas');
var hint = document.getElementById('sp7Hint');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_DIM = 200;
var RING_TEETH = 60;
var ROLL_TEETH_MIN = 14;
var ROLL_TEETH_MAX = 34;
var PEN_TIP_RATIO = 0.6;
var TOOTH_UNIT = 1.2;
var SEGMENTS_PER_TURN = 170;
var POINTER_MAX_DIST = 90;
var ROTATE_SPEED = 0.15;
var DEMO_CYCLE = 10;
var KEY_STEP = 0.08;
function gcd(a, b) { while (b) { var t = b; b = a % b; a = t; } return a; }
function computePath(rollTeeth) {
var R = RING_TEETH * TOOTH_UNIT;
var r = rollTeeth * TOOTH_UNIT;
var d = r * PEN_TIP_RATIO;
var K = (R - r) / r;
var g = gcd(RING_TEETH, rollTeeth);
var turns = rollTeeth / g;
var maxT = 2 * Math.PI * turns;
var step = (2 * Math.PI) / SEGMENTS_PER_TURN;
var pts = [];
for (var t = 0; t <= maxT; t += step) {
pts.push({
x: (R - r) * Math.cos(t) + d * Math.cos(K * t),
y: (R - r) * Math.sin(t) - d * Math.sin(K * t)
});
}
return pts;
}
var ratioParam = 0.5;
var currentRoll = -1;
var pathData = [];
var markerAngle = -Math.PI / 2;
var markerDist = POINTER_MAX_DIST * 0.5;
function rebuildForRatio(ratio) {
ratioParam = Math.max(0, Math.min(1, ratio));
var newRoll = Math.round(ROLL_TEETH_MIN + ratioParam * (ROLL_TEETH_MAX - ROLL_TEETH_MIN));
if (newRoll !== currentRoll) {
currentRoll = newRoll;
pathData = computePath(currentRoll);
}
}
function handlePointer(px, py) {
var dist = Math.hypot(px, py);
markerAngle = Math.atan2(py, px);
markerDist = Math.min(POINTER_MAX_DIST, dist);
rebuildForRatio(dist / POINTER_MAX_DIST);
}
rebuildForRatio(ratioParam);
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.86;
cx = cw / 2; cy = ch / 2;
}
var rot = 0;
function render() {
ctx.fillStyle = '#ff4d1f';
ctx.fillRect(0, 0, cw, ch);
ctx.beginPath();
ctx.arc(cx, cy, POINTER_MAX_DIST * scale, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,247,230,.28)';
ctx.lineWidth = Math.max(1, scale * 0.014);
ctx.setLineDash([scale * 0.05, scale * 0.06]);
ctx.stroke();
ctx.setLineDash([]);
var cosR = Math.cos(rot), sinR = Math.sin(rot);
ctx.beginPath();
for (var i = 0; i < pathData.length; i += 1) {
var p = pathData[i];
var rx = p.x * cosR - p.y * sinR;
var ry = p.x * sinR + p.y * cosR;
var sx = cx + rx * scale, sy = cy + ry * scale;
if (i === 0) { ctx.moveTo(sx, sy); } else { ctx.lineTo(sx, sy); }
}
ctx.strokeStyle = 'rgba(255,247,230,.95)';
ctx.lineWidth = Math.max(2, scale * 0.026);
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.stroke();
var mx = cx + Math.cos(markerAngle) * markerDist * scale;
var my = cy + Math.sin(markerAngle) * markerDist * scale;
ctx.beginPath();
ctx.arc(mx, my, Math.max(4, scale * 0.06), 0, Math.PI * 2);
ctx.fillStyle = 'rgba(23,20,26,.9)';
ctx.fill();
}
function onPointerMove(e) {
root.classList.remove('is-demo');
var rect = canvas.getBoundingClientRect();
var localX = (e.clientX - rect.left) - cw / 2;
var localY = (e.clientY - rect.top) - ch / 2;
handlePointer(localX / scale, localY / scale);
if (reduce) render();
}
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerdown', onPointerMove);
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
root.classList.remove('is-demo');
e.preventDefault();
var next = ratioParam + (e.key === 'ArrowRight' ? KEY_STEP : -KEY_STEP);
rebuildForRatio(next);
markerDist = ratioParam * POINTER_MAX_DIST;
if (reduce) render();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
fit();
if (reduce) {
root.classList.remove('is-demo');
render();
} else {
var startTs = null;
var lastTs = null;
function tick(ts) {
if (startTs === null) { startTs = ts; lastTs = ts; }
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
rot += ROTATE_SPEED * (dt / 1000);
if (root.classList.contains('is-demo')) {
var elapsed = (ts - startTs) / 1000;
var theta = elapsed * (2 * Math.PI / DEMO_CYCLE);
var wave = (Math.sin(elapsed * (2 * Math.PI / (DEMO_CYCLE * 0.5))) + 1) / 2;
var vx = Math.cos(theta) * wave * POINTER_MAX_DIST;
var vy = Math.sin(theta) * wave * POINTER_MAX_DIST;
handlePointer(vx, vy);
}
render();
requestAnimationFrame(tick);
}
window.addEventListener('resize', fit);
requestAnimationFrame(tick);
}
})();import { useEffect, useRef } from "react";
import styles from "./SpiroScenePointerRatioMorph.module.scss";
type Pt = { x: number; y: number };
export default function SpiroScenePointerRatioMorph() {
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;
const CALC_DIM = 200;
const RING_TEETH = 60;
const ROLL_TEETH_MIN = 14;
const ROLL_TEETH_MAX = 34;
const PEN_TIP_RATIO = 0.6;
const TOOTH_UNIT = 1.2;
const SEGMENTS_PER_TURN = 170;
const POINTER_MAX_DIST = 90;
const ROTATE_SPEED = 0.15;
const DEMO_CYCLE = 10;
const KEY_STEP = 0.08;
function gcd(a: number, b: number): number { while (b) { const t = b; b = a % b; a = t; } return a; }
function computePath(rollTeeth: number): Pt[] {
const R = RING_TEETH * TOOTH_UNIT;
const r = rollTeeth * TOOTH_UNIT;
const d = r * PEN_TIP_RATIO;
const K = (R - r) / r;
const g = gcd(RING_TEETH, rollTeeth);
const turns = rollTeeth / g;
const maxT = 2 * Math.PI * turns;
const step = (2 * Math.PI) / SEGMENTS_PER_TURN;
const pts: Pt[] = [];
for (let t = 0; t <= maxT; t += step) {
pts.push({
x: (R - r) * Math.cos(t) + d * Math.cos(K * t),
y: (R - r) * Math.sin(t) - d * Math.sin(K * t)
});
}
return pts;
}
let ratioParam = 0.5;
let currentRoll = -1;
let pathData: Pt[] = [];
let markerAngle = -Math.PI / 2;
let markerDist = POINTER_MAX_DIST * 0.5;
function rebuildForRatio(ratio: number) {
ratioParam = Math.max(0, Math.min(1, ratio));
const newRoll = Math.round(ROLL_TEETH_MIN + ratioParam * (ROLL_TEETH_MAX - ROLL_TEETH_MIN));
if (newRoll !== currentRoll) {
currentRoll = newRoll;
pathData = computePath(currentRoll);
}
}
function handlePointer(px: number, py: number) {
const dist = Math.hypot(px, py);
markerAngle = Math.atan2(py, px);
markerDist = Math.min(POINTER_MAX_DIST, dist);
rebuildForRatio(dist / POINTER_MAX_DIST);
}
rebuildForRatio(ratioParam);
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.86;
cx = cw / 2; cy = ch / 2;
}
let rot = 0;
function render() {
ctx!.fillStyle = "#ff4d1f";
ctx!.fillRect(0, 0, cw, ch);
ctx!.beginPath();
ctx!.arc(cx, cy, POINTER_MAX_DIST * scale, 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(255,247,230,.28)";
ctx!.lineWidth = Math.max(1, scale * 0.014);
ctx!.setLineDash([scale * 0.05, scale * 0.06]);
ctx!.stroke();
ctx!.setLineDash([]);
const cosR = Math.cos(rot), sinR = Math.sin(rot);
ctx!.beginPath();
for (let i = 0; i < pathData.length; i += 1) {
const p = pathData[i];
const rx = p.x * cosR - p.y * sinR;
const ry = p.x * sinR + p.y * cosR;
const sx = cx + rx * scale, sy = cy + ry * scale;
if (i === 0) { ctx!.moveTo(sx, sy); } else { ctx!.lineTo(sx, sy); }
}
ctx!.strokeStyle = "rgba(255,247,230,.95)";
ctx!.lineWidth = Math.max(2, scale * 0.026);
ctx!.lineJoin = "round"; ctx!.lineCap = "round";
ctx!.stroke();
const mx = cx + Math.cos(markerAngle) * markerDist * scale;
const my = cy + Math.sin(markerAngle) * markerDist * scale;
ctx!.beginPath();
ctx!.arc(mx, my, Math.max(4, scale * 0.06), 0, Math.PI * 2);
ctx!.fillStyle = "rgba(23,20,26,.9)";
ctx!.fill();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function onPointerMove(e: PointerEvent) {
root!.classList.remove("is-demo");
const rect = canvas!.getBoundingClientRect();
const localX = (e.clientX - rect.left) - cw / 2;
const localY = (e.clientY - rect.top) - ch / 2;
handlePointer(localX / scale, localY / scale);
if (reduce) render();
}
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerdown", onPointerMove);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
root!.classList.remove("is-demo");
e.preventDefault();
const next = ratioParam + (e.key === "ArrowRight" ? KEY_STEP : -KEY_STEP);
rebuildForRatio(next);
markerDist = ratioParam * POINTER_MAX_DIST;
if (reduce) render();
}
}
root.addEventListener("keydown", onKeyDown);
fit();
let raf = 0;
if (reduce) {
root.classList.remove("is-demo");
render();
} else {
let startTs: number | null = null;
let lastTs = 0;
const onResize = () => { fit(); };
function tick(ts: number) {
if (startTs === null) { startTs = ts; lastTs = ts; }
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
rot += ROTATE_SPEED * (dt / 1000);
if (root!.classList.contains("is-demo")) {
const elapsed = (ts - startTs) / 1000;
const theta = elapsed * (2 * Math.PI / DEMO_CYCLE);
const wave = (Math.sin(elapsed * (2 * Math.PI / (DEMO_CYCLE * 0.5))) + 1) / 2;
const vx = Math.cos(theta) * wave * POINTER_MAX_DIST;
const vy = Math.sin(theta) * wave * POINTER_MAX_DIST;
handlePointer(vx, vy);
}
render();
raf = requestAnimationFrame(tick);
}
window.addEventListener("resize", onResize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
canvas!.removeEventListener("pointermove", onPointerMove);
canvas!.removeEventListener("pointerdown", onPointerMove);
root!.removeEventListener("keydown", onKeyDown);
};
}
return () => {
cancelAnimationFrame(raf);
canvas!.removeEventListener("pointermove", onPointerMove);
canvas!.removeEventListener("pointerdown", onPointerMove);
root!.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sp7} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.sp7__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.sp7__hint}>← →</p>
<p className={styles.sp7__caption}>포인터가 화면 중심에서 멀어지는 만큼 톱니 이빨 수 값을 다시 계산해, 포인터를 움직이는 동안 무늬의 꽃잎 수와 촘촘함이 그 값에 따라 함께 바뀐다</p>
</div>
</main>
);
}07 Pattern Morphing Its Tooth Ratio by Pointer Distance — Measured area 11.26% · 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 UI07 Morphing — one word becoming another9 Text Particle Effect CSS Ideas From Pixels
Creative UI07 Phase morph — same coefficients, rotated phases, a pattern that shape-shiftsFourier Epicycles, 9 Ways (No Library)
Creative UI05 Distance-based linksCanvas Particle Animation: 9 Engine Patterns