GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Spinning Top Animation: 9 Canvas Physics Scenes

Spin, tilt, stagger, fall — a top's whole life fits in four verbs, and scene 03 turns the second one into a number you can change.

See All Nine Spinning Top Scenes Move Right on This Page

Every canvas in the grid below is already running when you scroll to it, and each one starts from the same small object: a top with an angular speed, a tilt, and an axis direction. Nothing waits for a click, except scene 06, where your pointer is the thing that makes the top spin.

Auto-plays · click a tile to jump to its section · all nine in one zip

Spinning Top Animation: 9 Scenes Where One Angular Speed Sets Tilt and Wobble

If you want a spinning top physics explanation you can touch, this set is it: one number, the angular speed, falls a little every 16 ms step, and the tilt, the axis circle, the drift and the fall all read that single number. A spinning top animated gif plays the same loop forever, while here every scene is recomputed each frame, so changing a constant at the top of an item changes what you see on the next frame.

The nine scenes run in the order the top's life goes: decay first, then the leaning axis, the growing sway, the wandering on the floor, the collision of two tops, the pointer that spins one, the stripes that blur, the fall, and finally a hero arena that mixes them. All of them share a makeTop() object and a drawTop() painter, so the code you read in each item below is only the part that item adds.

01Basic Spinning Top Whose Angular Speed Decays by Friction

decayAngularSpeed() multiplies the angular speed by an exponential factor built from ANGULAR_DECAY (0.55) on every 16 ms step, and the stripe angle grows by whatever speed is left, so the top spins fast and then settles. When the speed falls under 4 it jumps back to OMEGA_START (38) with a ripple, which makes it a good opening frame for a rotation scene.

Basic Spinning Top Whose Angular Speed Decays by Friction
Open in a new tab

01 · Basic Spinning Top Whose Angular Speed Decays by Friction — Measured area 22.972% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ANGULAR_DECAYdecayAngularSpeeddrawOmegaGauge
  var ANGULAR_DECAY = 0.55;
  var OMEGA_START = 38;
  var spinTop = makeTop(160, 158, 0, 58, OMEGA_START);

  function decayAngularSpeed(t) { t.omega *= Math.exp(-ANGULAR_DECAY * dtS); }

  function stepWorld() {
    decayAngularSpeed(spinTop);
    spinTop.spin += spinTop.omega * dtS;
    var slow = 1 - clamp(spinTop.omega / OMEGA_START, 0, 1);
    spinTop.tilt = 0.06 + 0.5 * slow * slow;
    spinTop.az += (2 + slow * 5) * dtS;
    if (spinTop.omega < 4) {
      spinTop.omega = OMEGA_START;
      addPulse(spinTop.x, spinTop.y + 4, '255,214,230');
    }
    stepPulses();
  }

Item 01 repainted 23.0% of the frame at an average intensity of 76.2 across 23/23 measured frames.

02Precessing Top Whose Tilted Axis Draws a Circle

PRECESSION_GAIN (0.6) only sets how far the axis leans, through TILT_PER_GAIN (0.7) times the gain, and the speed at which the axis circles comes from PRECESSION_K (200) divided by the angular speed inside precessAxis(). A white dot with a 110-point trail follows the axis tip, so the scene works as an educational picture of how a leaning axis keeps turning.

Precessing Top Whose Tilted Axis Draws a Circle
Open in a new tab

02 · Precessing Top Whose Tilted Axis Draws a Circle — Measured area 23.096% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PRECESSION_GAINprecessAxisTILT_PER_GAIN
  function precessAxis(t) {
    var rate = PRECESSION_K * Math.sin(t.tilt) / Math.max(t.omega, 8);
    t.az += rate * dtS;
  }

  function stepWorld() {
    spinTop.omega *= Math.exp(-AXIS_DECAY * dtS);
    spinTop.spin += spinTop.omega * dtS;
    var slow = 1 - clamp(spinTop.omega / OMEGA_FULL, 0, 1);
    spinTop.tilt = TILT_PER_GAIN * PRECESSION_GAIN * (1 + 0.5 * slow);
    precessAxis(spinTop);
    pushTrail(tipTrail, spinTop.tipX, spinTop.tipY, 110);
    if (spinTop.omega < 12) {
      spinTop.omega = OMEGA_FULL;
      addPulse(spinTop.x, spinTop.y + 4, '201,184,255');
    }
    stepPulses();
  }

Item 02 repainted 23.1% of the frame at an average intensity of 64.6 across 23/23 measured frames.

03Top Whose Tilt Amplitude Grows as It Slows

tiltAmplitude() returns TILT_AMP_MAX (0.88) times the share of speed already lost, raised to the 1.6 power, and a sine phase that quickens as the top slows swings the tilt back and forth inside that amplitude. A translucent fan on the canvas shows the sway widening, which suits the tense moment just before the spin stops.

Top Whose Tilt Amplitude Grows as It Slows
Open in a new tab

03 · Top Whose Tilt Amplitude Grows as It Slows — Measured area 20.204% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TILT_AMP_MAXtiltAmplitudedrawAmplitudeFan
  function tiltAmplitude(omega) {
    var slow = 1 - clamp(omega / OMEGA_TOP, 0, 1);
    return TILT_AMP_MAX * Math.pow(slow, 1.6);
  }

  function stepWorld() {
    spinTop.omega *= Math.exp(-OMEGA_FADE * dtS);
    spinTop.spin += spinTop.omega * dtS;
    currentAmp = tiltAmplitude(spinTop.omega);
    var slow = 1 - clamp(spinTop.omega / OMEGA_TOP, 0, 1);
    wobblePhase += (5 + 9 * slow) * dtS;
    spinTop.tilt = 0.05 + currentAmp * (0.55 + 0.45 * Math.sin(wobblePhase));
    spinTop.az += (3 + slow * 5) * dtS;
    if (spinTop.omega < 7) {
      spinTop.omega = OMEGA_TOP;
      addPulse(spinTop.x, spinTop.y + 4, '159,216,255');
    }
    stepPulses();
  }

Item 03 repainted 20.2% of the frame at an average intensity of 68.2 across 23/23 measured frames.

04Wandering Top Leaving a Friction Trace on the Floor

Each step adds a drift force of DRIFT_ACCEL (70) along a heading that keeps turning, multiplies the velocity by FLOOR_FRICTION (0.992), and then moves the contact point. Every 32 ms the position goes into floorTrace, which holds up to 420 points, so the pale line left on the floor turns the motion into a readable trace.

Wandering Top Leaving a Friction Trace on the Floor
Open in a new tab

04 · Wandering Top Leaving a Friction Trace on the Floor — Measured area 25.688% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLOOR_FRICTIONDRIFT_ACCELfloorTrace
  function stepWorld() {
    spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
    spinTop.spin += spinTop.omega * dtS;
    heading += HEADING_RATE * dtS;
    var drive = DRIFT_ACCEL * (spinTop.omega / OMEGA_BASE) * dtS;
    spinTop.vx += Math.cos(heading) * drive;
    spinTop.vy += Math.sin(heading) * drive * 0.7;
    spinTop.vx *= FLOOR_FRICTION;
    spinTop.vy *= FLOOR_FRICTION;
    spinTop.x += spinTop.vx * dtS;
    spinTop.y += spinTop.vy * dtS;
    wallBounce(spinTop, 0.6, 6);
    var slow = 1 - clamp(spinTop.omega / OMEGA_BASE, 0, 1);
    spinTop.tilt = 0.1 + 0.4 * slow * slow + 0.1 * Math.min(1, Math.hypot(spinTop.vx, spinTop.vy) / 120);
    spinTop.az = heading;
    if (elapsed % 32 === 0) pushTrail(floorTrace, spinTop.x, spinTop.y + 3, 420);
    if (spinTop.omega < 9) {
      spinTop.omega = OMEGA_BASE;
      spinTop.vx += 50; spinTop.vy -= 30;
      addPulse(spinTop.x, spinTop.y + 4, '168,230,207');
    }
    stepPulses();
  }

Item 04 repainted 25.7% of the frame at an average intensity of 60.8 across 23/23 measured frames.

05Two Tops Colliding and Bouncing Apart

pairCollide() stretches the vertical distance by 1.6 to account for perspective and counts a hit when that distance is under 0.92 times the sum of the radii, then pushes the tops apart and gives each an impulse along the contact normal scaled by COLLISION_RESTITUTION (0.9). It also swaps 30% of the angular speed difference (SPIN_MIX) between them, so the total spin is unchanged and the slower top speeds up, which is what a head-to-head scene needs.

Two Tops Colliding and Bouncing Apart
Open in a new tab

05 · Two Tops Colliding and Bouncing Apart — Measured area 40.629% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

COLLISION_RESTITUTIONresolveTopPairexchangeSpin
  function pairCollide(a, b, rest, spinMix, kickAmt) {
    var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
    var dist = Math.hypot(dx, dy);
    var minDist = (a.R + b.R) * 0.92;
    if (dist <= 0 || dist >= minDist) return false;
    var nx = dx / dist, ny = dy / dist;
    var overlap = (minDist - dist) * 0.5;
    a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
    b.x += nx * overlap; b.y += ny * overlap / 1.6;
    var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
    if (vn >= 0) return false;
    var j = -(1 + rest) * vn / 2;
    a.vx -= j * nx; a.vy -= j * ny;
    b.vx += j * nx; b.vy += j * ny;
    var tr = (b.omega - a.omega) * spinMix;
    a.omega += tr; b.omega -= tr;
    a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
    b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
    a.kick = kickAmt; b.kick = kickAmt;
    addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
    return true;
  }

Item 05 repainted 40.6% of the frame at an average intensity of 72.1 across 23/23 measured frames.

06Top That Gains Spin When the Pointer Sweeps Over It

impartSpin() takes the vector from the top's center to the pointer and the vector the pointer just moved, crosses them into a torque, and adds torque × SPIN_PER_PX (0.0045) to the angular speed, clamped to plus or minus OMEGA_MAX (44). Sweeping above the center spins the top one way and sweeping below spins it the other, a faster sweep adds more, and until you touch it the page plays four scripted swipes.

Top That Gains Spin When the Pointer Sweeps Over It
Open in a new tab

06 · Top That Gains Spin When the Pointer Sweeps Over It — Measured area 15.637% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPIN_PER_PXimpartSpinvirtualSwipe
  function impartSpin(px, py, dx, dy) {
    var cx = spinTop.x, cy = spinTop.y - 34;
    var rx = px - cx, ry = (py - cy) * 1.4;
    if (Math.hypot(rx, ry) > spinTop.R * 1.7) return;
    var torque = rx * dy - ry * dx;
    spinTop.omega = clamp(spinTop.omega + torque * SPIN_PER_PX, -OMEGA_MAX, OMEGA_MAX);
    spinTop.kick = Math.min(0.6, spinTop.kick + Math.abs(torque) * 0.004);
  }

Item 06 repainted 15.6% of the frame at an average intensity of 73.1 across 23/23 measured frames.

07Afterimage Top Whose Stripes Blur When Fast

The stripe passing rate, angular speed times STRIPE_COUNT (6) divided by 2π, is compared with STRIPE_FUSE_HZ (14): above it a mid-tone disc fades in over the stripes, and three earlier stripe angles are layered at falling opacity as an afterimage. The speed climbs to 46, decays exponentially and climbs again once it reaches 6, so every loop shows the stripes melting into a band and sharpening again.

Afterimage Top Whose Stripes Blur When Fast
Open in a new tab

07 · Afterimage Top Whose Stripes Blur When Fast — Measured area 20.401% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STRIPE_COUNTSTRIPE_FUSE_HZrevUp
    var sgn = t.omega < 0 ? -1 : 1;
    var w = Math.abs(t.omega);
    wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
    if (w > 8) {
      for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
    }
    var f = w * STRIPE_COUNT / 6.2832;
    var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
    if (blur > 0) {
      ctx.globalAlpha = blur;
      ctx.beginPath();
      ctx.arc(0, 0, R, 0, 6.2832);
      ctx.fillStyle = t.pal.mid;
      ctx.fill();
      ctx.globalAlpha = 1;
    }

Item 07 repainted 20.4% of the frame at an average intensity of 60.4 across 23/23 measured frames.

08Tumble Check That Lays the Top Down Past a Tilt Limit

judgeTumble() derives the tilt from how much speed is gone and returns true only when the angular speed is under FALL_OMEGA (9) and the tilt is over TILT_LIMIT (0.62). The top then switches from spin to fall, gravity adds to its tilt speed, a hard enough hit bounces it back at 28% of its speed, and it lies down for 1100 ms before spinning again while dashed lines mark the limit angle.

Tumble Check That Lays the Top Down Past a Tilt Limit
Open in a new tab

08 · Tumble Check That Lays the Top Down Past a Tilt Limit — Measured area 22.438% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TILT_LIMITFALL_OMEGAjudgeTumble
  function stepTop(t) {
    if (t.state === 'spin') {
      t.omega *= Math.exp(-OMEGA_FADE * dtS);
      t.spin += t.omega * dtS;
      t.az += (3 + (1 - t.omega / OMEGA_START) * 4) * dtS;
      if (judgeTumble(t)) { t.state = 'fall'; t.tv = 0.5; t.az = 0.35 + (t.x < 160 ? 0 : 2.4); }
    } else if (t.state === 'fall') {
      t.spin += t.omega * dtS;
      t.tv += 18 * Math.sin(t.tilt) * dtS;
      t.tilt += t.tv * dtS;
      if (t.tilt >= 1.5) {
        t.tilt = 1.5;
        if (t.tv > 0.9) { t.tv = -t.tv * 0.28; addPulse(t.x, t.y + 4, '255,255,255'); }
        else { t.tv = 0; t.state = 'down'; t.rest = 0; }
      }
    } else {
      t.omega *= 0.97;
      t.spin += t.omega * dtS;
      t.rest += FIXED_DT;
      if (t.rest > 1100) {
        t.state = 'spin'; t.omega = OMEGA_START; t.tilt = 0.08;
        addPulse(t.x, t.y + 4, '168,230,207');
      }
    }
  }

Item 08 repainted 22.4% of the frame at an average intensity of 68.4 across 23/23 measured frames.

09Hero Arena Combining Spin, Precession, and Collisions

heroStep() runs three tops of radius 24 through one loop that applies speed decay (HERO_DECAY 0.22), precession (HERO_PRECESSION_GAIN 0.9), a pull toward the arena center, floor friction (HERO_FRICTION 0.995), and pairCollide() for every pair. The tops bump each other and slow down, and any top whose speed falls under 9 is restarted at full speed, so the scene loops indefinitely as a hero background; the code has no winner rule, so add your own if you need one.

Hero Arena Combining Spin, Precession, and Collisions
Open in a new tab

09 · Hero Arena Combining Spin, Precession, and Collisions — Measured area 28.945% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HERO_PRECESSION_GAINHERO_RESTITUTIONheroStep
    for (i = 0; i < heroTops.length; i += 1) {
      t = heroTops[i];
      t.omega *= Math.exp(-HERO_DECAY * dtS);
      t.spin += t.omega * dtS;
      var slow = 1 - clamp(Math.abs(t.omega) / HERO_OMEGA_START, 0, 1);
      t.kick = Math.max(0, t.kick - dtS * 0.9);
      t.tilt = HERO_PRECESSION_GAIN * 0.3 * (1 + slow) + t.kick * 0.5;
      t.az += 150 * Math.sin(t.tilt) / Math.max(Math.abs(t.omega), 8) * dtS;
      var cxs = 160 - t.x, cys = 120 - t.y, d = Math.hypot(cxs, cys) || 1;
      t.vx += (cxs / d * 14 + Math.cos(t.az) * HERO_DRIFT * 0.4) * dtS;
      t.vy += (cys / d * 14 + Math.sin(t.az) * HERO_DRIFT * 0.3) * dtS;
      t.vx *= HERO_FRICTION; t.vy *= HERO_FRICTION;
      t.x += t.vx * dtS; t.y += t.vy * dtS;
      wallBounce(t, 0.85, 6);
      if (elapsed % 48 === 0) pushTrail(heroTrails[i], t.x, t.y + 3, 70);
      if (Math.abs(t.omega) < 9) {
        t.omega = (i === 1 ? -1 : 1) * HERO_OMEGA_START;
        addPulse(t.x, t.y + 4, '255,241,201');
      }
    }

Item 09 repainted 28.9% of the frame at an average intensity of 64.0 across 23/23 measured frames.

Code for Each Spinning Top Technique

Reading the nine rows together, the repaint column peaks at 05 (40.6%), where two tops cross the canvas and every collision adds a ripple, and bottoms out at 06 (15.6%), where one top stays in place and only the pointer ring moves. Item 04 is the odd one: its largest single frame covers only 3.9% of the canvas, yet the floor trace accumulates to 25.7% over the loop. Average intensity ranges from 60.4 in 07 to 76.2 in 01, so the item with the lowest average intensity is the one whose stripes fuse into a band.

Item Technique Repaint area Avg. intensity Moved frames
01 Angular speed decay 23.0% 76.2 23/23
02 Precession axis circle 23.1% 64.6 23/23
03 Growing tilt amplitude 20.2% 68.2 23/23
04 Floor friction trace 25.7% 60.8 23/23
05 Two-top collision 40.6% 72.1 23/23
06 Pointer spin torque 15.6% 73.1 23/23
07 Afterimage stripes 20.4% 60.4 23/23
08 Tumble judgment 22.4% 68.4 23/23
09 Hero arena 28.9% 64.0 23/23

Where the Values Change the Scene — Angular Speed, Friction, Precession Gain

Three groups of constants decide most of what you see. The decay rates (ANGULAR_DECAY, AXIS_DECAY, OMEGA_FADE, HERO_DECAY) set how long the spin lasts, the friction and drift constants (FLOOR_FRICTION, DRIFT_ACCEL, HERO_FRICTION) set how far a top wanders, and the gain and amplitude constants (PRECESSION_GAIN, TILT_AMP_MAX, TILT_LIMIT) set how far it leans. None of them touches the loop structure, because each only scales a number that the step function already reads, in the same spirit as the bounce and restitution constants in 9 Bouncing Ball Animation Patterns With Physics. The variant table further down changes one value per row so you can watch the scene shift.

A full derivation of spinning top mechanics is not in the code, because every scene is a hand-tuned approximation: the angular speed fades by a rate, the tilt is a formula of the speed that is left, and nothing integrates forces on a rigid body. That is exactly why the scenes run on a phone, and why a constant can be changed without breaking an equation. The rotating-disc idea in Spin Wheel: 9 Canvas Roulettes, Flick to Stop uses the same exponential fade on its wheel.

Where It Breaks — Just Before Stopping, Narrow Screens, Overlapping Collisions

Just before stopping is where a top animation usually fails, and here the code guards it in two places: precessAxis() divides by Math.max(t.omega, 8), so the axis rate cannot blow up as the speed nears zero, and the loop scenes restart the speed when it drops under a floor value, such as 4 in item 01 or 9 in item 09. The fall check in item 08 hides a detail too: with the tilt formula 0.08 + 0.95 × slow^2.2, the tilt limit of 0.62 is only crossed when the speed is under about 6.8, so by the time the tilt test passes, the FALL_OMEGA test of 9 is already true. On a narrow screen fit() resizes the canvas to its wrapper and only the drawing scale sc changes, because the physics keeps working in a fixed 320 by 200 space, so the tops simply get smaller and the scene itself stays the same. Collisions are the third weak point: the 09 loop resolves each pair one after another, so a top pushed by the first pair can still overlap the next pair before its turn comes, and pairCollide() skips the impulse for a pair that is already separating. The nine index.html files behind every item above come zipped together with password tqsqe3wp so you can raise a constant until your own scene breaks.

Three Variants and Accessibility

Variant Value Effect
Adjust the precession gain 02 PRECESSION_GAIN 0.6 → 1.4 At full speed the tilt is 0.7 times the gain, so it grows from 0.42 to 0.98: the axis tip sweeps a much wider circle and the body leans far over, while a lower value keeps the top almost upright.
Adjust the floor friction ratio 04 FLOOR_FRICTION 0.992 → 0.97 Dropping the ratio is stronger friction, so the speed the drift force can sustain falls to about a quarter and the top wanders much less, while the higher value lets it glide far and draw a long trace.
Adjust the afterimage stripe count 07 STRIPE_COUNT 6 → 12 Raising the count packs the stripes tighter, so the blur starts when the angular speed passes about 7.3 instead of about 14.7, while a lower count keeps thick stripes clear until the top slows down.

Every one of the nine Canvas API scenes reads prefers-reduced-motion through matchMedia before it starts its requestAnimationFrame loop. With reduced motion on, all nine run a short warm-up of fixed steps (30, 40, 40, 30, 30, 20, 40, 20 and 60 for items 01 to 09), draw one settled frame and never start the loop. Only item 06 has tabindex="0" and listens for input: pointer drags, the left and right arrow keys, and the space bar change the spin even while the animation is off, and the page's four scripted swipes are disabled.

What Does the Code Calculate to Tilt a Spinning Top?

In every scene the tilt is a function of how much angular speed is gone, written as slow = 1 - omega / start: item 01 uses 0.06 + 0.5 × slow², item 03 multiplies an amplitude by a sine, item 08 raises slow to the 2.2 power, and item 02 sets it from the gain. drawTop() then places the top end of the axis from the tilt and the axis direction az, using the sine and cosine of those two angles, and the body is squashed vertically by a factor that grows with the tilt. The perspective floor grid, the glow pool under the top, and the translucent spin-blur disc are a presentation layer; the speeds, tilts, positions and collision impulses are the calculation, and the same computed-not-recorded idea drives HTML5 Canvas Animation: 9 Warp & Droplet Effects and JavaScript Physics Simulation: 9 Demos, No Library.

Frequently Asked Questions Before You Use These Spinning Top Scenes

Does item 06 work like a spinning top toy with string?

Not exactly: nobody pulls a string, and the code has no string at all. Instead the cross product of your swipe and the vector from the top's center adds a torque, and holding the space bar keeps raising the speed in whichever direction the top already turns.

How does the code decide that a top has fallen?

judgeTumble() in item 08 returns true when the angular speed is under FALL_OMEGA and the tilt is over TILT_LIMIT. Then the state changes from spin to fall and gravity takes over the tilt.

Can I use one of these scenes on my own page?

Yes. Every scene is one self-contained index.html with its own canvas, constants and step function, so you can lift a single top into a hero section without bringing the other eight along.

Enter the archive password

The password is inside this article. You will find it as you read.