Aduok Code

A Clockwork Spider Clock, Built from Procedurally Generated SVG Cogs, One Spring-Eased Tick Loop, and a Spider Hanging by a Thread

Introduction

This clock has no animation library, no image assets, and no hand-traced SVG paths for its gears. Every cog is generated from two numbers, every tick mark and numeral is placed by one helper function, and a single requestAnimationFrame loop reads the system clock and sets the rotation of every moving part — three hands and seven cogs — in the same frame.

The original inspiration animated the spider with a paid morphing plugin and hundreds of lines of pasted path data. The interesting problem is how much of that you can replace with plain arithmetic: a toothed outline is just a list of points on two circles, and a spring-like tick is just an easing function evaluated against the milliseconds of the current second.

What you will learn

How to generate a gear outline from a tooth count and two radii — how one polar() helper places every tick and numeral — why hands are drawn pointing up at the origin and rotated with a single transform — how easeOutBack over the first 450ms of each second produces a mechanical tick — and the CSS-versus-SVG transform collision that silently breaks an animated group.

01
Part One
Cogs Generated From a Tooth Count

Step 1 — A Toothed Path Is Just Points on Two Circles

A gear outline alternates between a root radius (the valley between teeth) and an outer radius (the tooth tip). For each tooth, four points are enough: root at the start angle, outer at 20% of the pitch, outer at 50%, root at 70%. The trapezoid that results reads as a tooth with slightly angled flanks, and the remaining 30% of the pitch is the flat valley before the next tooth.

JavaScript
function toothedPath(teeth, outer, root) {
  const pitch = 360 / teeth;
  const pts = [];
  for (let i = 0; i < teeth; i++) {
    const a = i * pitch;
    pts.push([root, a], [outer, a + pitch * 0.2], [outer, a + pitch * 0.5], [root, a + pitch * 0.7]);
  }
  return pts.map(([r, a], i) => {
    const x = r * Math.cos(a * Math.PI / 180);
    const y = r * Math.sin(a * Math.PI / 180);
    return `${i ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)}`;
  }).join(" ") + " Z";
}

Every cog on the page is then one entry in a config array: position, tooth count, radius, spin direction, opacity, and blur. Adding an eighth cog is one new object, not a new path. The two large, faint cogs at the edges use an feGaussianBlur filter so they read as out-of-focus machinery behind the dial, which gives the scene depth without any extra drawing.

JavaScript
const COGS = [
  { x:  60, y:  70, teeth: 24, r: 62,  dir:  1, opacity: .34, blur: 0 },
  { x: 330, y:  80, teeth: 16, r: 44,  dir: -1, opacity: .36, blur: 0 },
  { x:-40,  y: 200, teeth: 30, r: 120, dir: -1, opacity: .10, blur: 2.4 },
  // ...
];
An honest shortcut

These cogs do not truly mesh. Their teeth are not phase-aligned and their positions are eyeballed. The illusion comes from alternating spin direction and advancing each cog by exactly one tooth-step per second, which is enough for the eye at this opacity.

02
Part Two
A Dial Built With One Polar Helper

Step 2 — polar(radius, degrees) Places Everything

Clock faces are polar by nature, but SVG coordinates are Cartesian. One small helper bridges them. It subtracts 90 degrees so that 0 means twelve o’clock instead of three, and it returns an [x, y] pair around the dial center. Sixty ticks and twelve numerals then become two loops with no hand-placed coordinates.

JavaScript
const polar = (radius, degrees) => {
  const rad = (degrees - 90) * Math.PI / 180;
  return [CENTER + radius * Math.cos(rad), CENTER + radius * Math.sin(rad)];
};

for (let i = 0; i < 60; i++) {
  const major = i % 5 === 0;
  const [x1, y1] = polar(major ? 168 : 174, i * 6);
  const [x2, y2] = polar(182, i * 6);
  make("line", { class: major ? "tick-major" : "tick-minor", x1, y1, x2, y2 }, dial);
}

Numerals use text-anchor: middle and dominant-baseline: central so each digit is centered on its polar point rather than sitting on it by its baseline. Without those two properties, the 6 and the 12 look vertically off even though the math is right.

03
Part Three
Hands Drawn Upright, Rotated by Transform

Step 3 — Pointing Up at the Origin

Each hand is a small tapered path drawn as if it were pointing at twelve o’clock, with its pivot at (0, 0). Placing it is then a single transform: translate to the dial center, then rotate. Because the path is authored around the origin, there is no transform-origin to configure and no bounding-box surprises across browsers.

JavaScript
const setAngle = (node, degrees) =>
  node.setAttribute("transform", `translate(${CENTER} ${CENTER}) rotate(${degrees})`);

setAngle(hands.second, (seconds + tick) * 6);
setAngle(hands.minute, (minutes + (seconds + ms / 1000) / 60) * 6);
setAngle(hands.hour,   (hours + minutes / 60) * 30);

The hour and minute hands are continuous: they include the fractional part of the smaller unit, so the hour hand creeps between numerals instead of jumping on the hour. Only the second hand uses the spring, which is what makes it feel like an escapement while the other two feel like geared drive.

04
Part Four
One Loop, One Spring, Every Cog

Step 4 — easeOutBack Against the Current Millisecond

There is no timeline and no tween that gets restarted every second. The loop asks the clock what time it is, computes how far into the current second it is, and turns that into a progress value from 0 to 1 over the first 450ms. An easeOutBack curve applied to that value overshoots slightly past 1 and settles back, which is exactly the recoil of a mechanical second hand.

JavaScript
const easeOutBack = (t) => {
  const c1 = 2.2, c3 = c1 + 1;
  return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
};

const phase = Math.min(ms / 450, 1);
const tick = reducedMotion ? (ms > 0 ? 1 : 0) : easeOutBack(phase);

Because the animation is a pure function of the current time, it can never drift, never desync after the tab sleeps, and never need to be restarted. Reload the page at 3:47:12.300 and every part is already exactly where it should be.

The same tick value drives the cogs. Each cog turns by (wholeSeconds + tick) * step * dir, where step is 360 divided by its tooth count. Every cog therefore advances one tooth per second, in sync with the second hand, and the modulo 360 keeps the number small no matter how long the page stays open.

05
Part Five
A Spider on a Thread, and a Transform Trap

Step 5 — Mirrored Legs and the CSS-vs-SVG Transform Collision

The spider’s eight legs are defined once, as four knee-and-foot pairs for the right side. The left side is generated by multiplying the x values by -1. Each leg is a three-point polyline with round joins, which is enough to read as a jointed limb at this size.

JavaScript
const LEGS = [
  [[18, -26], [36, -14]],
  [[24, -12], [44, 0]],
  [[24, 2],   [42, 16]],
  [[16, 10],  [30, 28]],
];
for (const side of [-1, 1]) {
  for (const [knee, foot] of LEGS) {
    make("path", {
      class: "spider-leg",
      "stroke-linejoin": "round",
      d: `M0 2 L${side * knee[0]} ${knee[1]} L${side * foot[0]} ${foot[1]}`,
    }, legs);
  }
}

The trap: a CSS transform (including one set by an animation) fully replaces an SVG element’s transform attribute; they do not combine. The legs group needs to be positioned at the dial center and also play a subtle flex animation. Put both on one element and the animation wipes out the position, sending the legs to the top-left corner of the SVG.

JavaScript
// Outer group holds the position (SVG attribute).
const legAnchor = make("g", { transform: `translate(${CENTER} ${CENTER})` }, spider);
// Inner group holds the animation (CSS), scaling around its own origin.
const legs = make("g", { class: "legs" }, legAnchor);
CSS
.legs { transform-origin: 0 0; animation: skitter 4s ease-in-out infinite; }

@keyframes skitter {
  0%, 82%, 100% { transform: scale(1); }
  88% { transform: scale(1.09, .95); }
  94% { transform: scale(.97, 1.04); }
}

The thread is a plain fixed element, half the viewport tall and centered, that scales down from the top on load using transform-origin: top. It sits at the exact horizontal center of the page, which is also the center of the SVG, so the spider always appears to hang from it. Both the thread animation and the leg flex are switched off under prefers-reduced-motion, and the second hand switches from a spring to a plain step.

Tuning Reference

PropertyExampleEffect
teeth / r (per cog)24 / 62Controls tooth density, outline size, and how fast the cog appears to turn
blur (per cog)0 – 2.4Adds depth; large faint cogs read as background machinery
tick window450msHow long the second hand spends moving before it rests
easeOutBack c12.2Higher values overshoot more and read as a snappier escapement
transform splitattr + CSSPosition on an outer SVG group, animation on an inner CSS group

Full Source Code

The whole component is a single self-contained HTML file — markup, styles, and script, with no build step. The only external request is an optional Google Font, and the numerals fall back to Georgia if it does not load.

HTML — clockwork-spider.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/clockwork-spider.html
Want to try it yourself?Open the live clockwork-spider.html file