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

Spin Wheel: 9 Canvas Roulettes, Flick to Stop

It spins exactly as hard as you flick it. Angular velocity is integrated, friction decays it, and the sector under the pointer is judged by math — one canvas,

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

Demo

All nine roulettes below share the same three-step chain — a pointer flick becomes angular velocity, a requestAnimationFrame loop decays that velocity by a friction coefficient raised to the power of the frame's delta time, and the resting angle is normalized against a fixed twelve-o'clock pointer to pick the winning sector. Nothing here is a CSS transition wearing a deceleration curve; every frame actually recomputes the disc's angle from the physics state, so a harder flick genuinely spins longer and a lighter one settles sooner.

The winner is angle math — pointer judging and weighted odds

None of these nine screens store a winner anywhere before the wheel stops; every one of them recomputes it from the resting angle the instant angularVelocity crosses the stop threshold, using Math.atan2 at the drag stage and a plain modulo division at the judging stage. That symmetry is also why item 01's plain sector wheel paints the widest footprint of the set at 23.344% cumulative area (22.728% at its single busiest frame) — an unobstructed disc with nothing else sharing the screen — while item 03's split graph view and item 08's side log panel are the two structurally narrower exceptions rather than any kind of rendering defect.

Weighted odds are angles, not labels

Item 04's cumulative-bounds array is the same technique any raffle needs once entries stop being equal, and it composes cleanly with the requestAnimationFrame loop every other item already runs — nothing about weighting the odds requires a second physics system.

A quieter cousin: canvases that compute rather than decorate

Two other builds on this site lean on the same principle of drawing from a live calculation instead of a static asset — a stack of falling blocks cleared by collision checks, frame by frame and a pile of grains settling cell by cell under simulated gravity both recompute their entire scene from state every frame, the same discipline that keeps a spin wheel's resting angle honest.

Why the demo never builds two separate loops

Autoplay here cycles a fixed array of flick velocities through the exact same friction-integration function a real drag would use, specifically so the recorded gif and a visitor's own flick can never disagree about what the physics does.

Why a flick is physics — integrating angular velocity, decaying it with friction

From pointer delta to angular velocity

Every pointermove measures the angle from the disc's center with Math.atan2(dy, dx), and the change in that angle since the previous frame, divided by the elapsed time, is angular velocity in radians per second — smoothed with a 0.65/0.35 exponential moving average so a single jittery sample can't spike the spin. The value captured at release becomes the wheel's initial velocity, which is the entire reason a soft flick and a hard flick produce visibly different spins rather than the same canned animation playing back at one speed.

Friction as an exponential decay, not a countdown

Each frame the loop measures dt from performance.now(), integrates angle += angularVelocity * dt, and then shrinks the velocity itself with angularVelocity *= Math.pow(friction, dt) — a curve that falls fast right after the flick and flattens out near the stop, never a linear ramp and never a fixed number of frames. The loop only ends once the absolute velocity drops under a small threshold, so a flick with more initial energy keeps the disc turning measurably longer without any extra branching logic to fake the difference.

Where the pointer decides the winner

Because the pointer sits fixed at twelve o'clock, the winning sector isn't stored anywhere — it's recovered by normalizing the resting angle against -Math.PI / 2 and dividing by the sector's angular width, an approach any spin wheel picker needs if the draw is going to feel earned rather than staged. The nine items below apply that same three-step chain to a plain sector wheel, a weighted-odds draw, an elimination bracket, and more, so the underlying math never changes even as the screen around it does.

Code per tool in nine steps

Paste any single item's prompt from the zip into an AI coding tool and it reproduces the same scene — the block below is the calculation core of each, trimmed to the function that carries its physics, which is also the same chain that would sit behind any spin wheel generator built from scratch rather than assembled from a plugin.

01The sector wheel — a plate of polar arcs joined into a disc

moveTo(0, 0) then arc(startAngle, endAngle) then closePath draws one wedge, and repeating that eight times around 2π/8 finishes the base plate before any physics ever touches it.

The sector wheel — a plate of polar arcs joined into a disc
Open in a new tab

01 · The sector wheel — a plate of polar arcs joined into a disc — Measured area 23.344% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arcbeginPathfillText
function norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }

// Winning sector against the fixed 12 o'clock pointer — undo the disc's -π/2 rotation, then divide by the sector angle
function judge() {
  return Math.floor(norm(-Math.PI / 2 - ang) / SEG) % N;
}

Eight wedges, one palette, zero repeated neighbors

The plate itself never animates; judge() is the only function that runs after the disc stops, and it is the same one-line calculation every other item in this post reuses under a different name.

02Flick to angular velocity — turning pointer travel into spin

A drag on this wheel only ever writes one number, drag.v, and everything downstream — the spin, the stop, the winner — depends on nothing else.

Flick to angular velocity — turning pointer travel into spin
Open in a new tab

02 · Flick to angular velocity — turning pointer travel into spin — Measured area 23.217% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovepointerdownMath.atan2
field.addEventListener('pointermove', function (e) {
  if (!drag.on) return;
  var a = angleAt(e), now = performance.now();
  var dA = a - drag.lastA;
  if (dA > Math.PI) dA -= TAU;
  if (dA < -Math.PI) dA += TAU;
  ang += dA;
  var dt = Math.max(1, now - drag.lastT) / 1000;
  drag.v = 0.65 * drag.v + 0.35 * (dA / dt);
  drag.lastA = a; drag.lastT = now;
  velEl.textContent = (drag.v).toFixed(1);
  idle = 1.2;
});

Why the ±π wraparound correction matters

Skipping the two wraparound lines would make the wheel jump backward every time a drag crosses the seam between +π and −π, since a raw angle difference there jumps by roughly 2π instead of by the small motion your hand actually made.

03Friction decay — a disc that integrates each frame to a stop

A second canvas beside the wheel plots the same angular velocity live, so the curve on the graph and the wheel's own deceleration are reading off the identical number every frame.

Friction decay — a disc that integrates each frame to a stop
Open in a new tab

03 · Friction decay — a disc that integrates each frame to a stop — Measured area 9.394% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameperformance.now
if (angularVelocity !== 0) {
  ang += angularVelocity * dt;
  var before = Math.abs(angularVelocity);
  angularVelocity *= Math.pow(COEFS[ci], dt);
  t0 += dt;
  if (Math.abs(angularVelocity) < STOP) {
    angularVelocity = 0; settled = true; settleAt = t0;
    secEl.textContent = settleAt.toFixed(1);
    hist.push({ t: t0, v: 0 });
  } else {
    hist.push({ t: t0, v: Math.abs(angularVelocity) });
  }
  if (hist.length > 200) hist.shift();
} else if (settled && t0 > settleAt + 1.0) {
  relaunch();
} else if (!settled) { relaunch(); }

Three coefficients, three shapes of the same curve

Switching between the three friction buttons keeps the flick strength fixed and only changes the exponent's base, which is why the wheel's painted footprint measures narrower here (9.394% cumulative, 7.138% at its peak frame) than any other item — half the screen is spent on the graph panel rather than the disc itself, a layout choice, not a rendering shortfall.

04Weighted odds — splitting sector angles by weight for a fair draw

Give six entries the weights [5, 3, 2, 1, 3, 2] and their sector boundaries stop being equal slices of 2π/6 and start being cumulative shares of the total weight instead.

Weighted odds — splitting sector angles by weight for a fair draw
Open in a new tab

04 · Weighted odds — splitting sector angles by weight for a fair draw — Measured area 22.052% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.randomfillTextarc
function norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
// Resting angle -> sector index (fixed 12 o'clock pointer)
function idxAt(a) {
  var u = norm(-Math.PI / 2 - a);
  for (var k = 0; k < W.length; k++) if (u >= bounds[k] && u < bounds[k + 1]) return k;
  return W.length - 1;
}

The angle on screen is the actual probability

idxAt doesn't know or care that the sectors are unequal — it just walks the same cumulative-bounds array that drew them, which is the whole point: the odds are never a separate number quoted in a label, they're the literal width of the wedge under the pointer.

05The ticking pointer — a flick sound and wobble at every sector edge

A fixed pointer above the wheel springs and clicks every time a sector edge passes beneath it, using a damped spring (pk=340, pc=14) rather than a canned wobble animation.

The ticking pointer — a flick sound and wobble at every sector edge
Open in a new tab

05 · The ticking pointer — a flick sound and wobble at every sector edge — Measured area 22.926% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameOscillatorNoderotate
// A changed quotient means one peg passed — bump the tick count and the spring velocity
function tickCheck() {
  var q = Math.floor(ang / SEG);
  if (q !== lastQ) {
    var d = q - lastQ;
    ticks += Math.abs(d);
    pV += (d > 0 ? 1 : -1) * Math.abs(vel || drag.v || 6) * 26;
    if (drag.on) tickSound();        // sound plays only while a human hand is spinning it
    ticksEl.textContent = String(ticks);
    lastQ = q;
  }
}

Silent by design during autoplay and capture

tickSound() only fires inside the if (drag.on) guard, so the seeded autoplay loop and every gif or mp4 capture of this item carry no audio at all — that's not a missing feature, it's the same gate that keeps a shared screen from making noise nobody asked for.

06Winner judging — converting the resting angle into a sector index

Once the disc stops, one function reads the angle, announces the name, and re-arms the wheel for another round without a page reload.

Winner judging — converting the resting angle into a sector index
Open in a new tab

06 · Winner judging — converting the resting angle into a sector index — Measured area 22.293% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.atan2aria-livefillText
function judge() {
  var deg = Math.round(norm(ang) * 180 / Math.PI);
  restEl.textContent = String(deg);
  var idx = idxAt(ang);
  lastWin = idx;
  winEl.textContent = 'No. ' + String(idx + 1);
  nameEl.textContent = NAMES[idx];
  liveEl.textContent = 'No. ' + (idx + 1) + ' ' + NAMES[idx];
}

Announcing a result nobody has to squint to see

Writing the same result into an aria-live="polite" element as into the visible name label means a screen-reader user gets the winner the instant it's decided, not only whoever happens to be looking at the canvas.

07The entry editor — a wheel whose labels and angles recompute together

Typing a name checks its pixel width with measureText and binary-searches the font size down until the label fits inside its own wedge's arc length.

The entry editor — a wheel whose labels and angles recompute together
Open in a new tab

07 · The entry editor — a wheel whose labels and angles recompute together — Measured area 21.903% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillTextmeasureTextchange
// Font size to fit the arc length — binary search, narrowing the bound by measuring width with measureText
function fitFont(text, arcLen) {
  var lo = 6, hi = 15, best = 6;
  for (var it = 0; it < 7; it++) {
    var mid = (lo + hi) / 2;
    ctx.font = '700 ' + mid + 'px sans-serif';
    if (ctx.measureText(text).width <= arcLen) { best = mid; lo = mid; } else { hi = mid; }
  }
  return best;
}

The brightest screen in the set, for a reason

This item measures the highest average pixel intensity of all nine at 62.0, because every added or removed entry forces a full relayout of light-colored wedges and freshly fitted text rather than a static background sitting mostly still.

08Elimination mode — the wheel spins on without its winners

Drawing a name here doesn't just announce it — alive.splice removes that entry from the pool and the remaining wedges redraw across the full circle again.

Elimination mode — the wheel spins on without its winners
Open in a new tab

08 · Elimination mode — the wheel spins on without its winners — Measured area 21.825% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectarcchange
// Commit — remove the drawn entry and redistribute the angles
function commit() {
  if (previewIdx < 0 || !alive.length) return;
  var winner = alive.splice(previewIdx, 1)[0];
  log.push(winner);
  previewIdx = -1; settle = -1; pulse = -1;
  renderLog();
}

An undo that actually reverses the draw

Popping that same index back off log and pushing it into alive is what makes this usable for a real bingo night, and it's also why this item's painted footprint (21.825% cumulative) sits a touch under the other seven wide-wheel items — a side log panel for tracking eliminated names splits the screen's width, the same structural reason item 03's graph panel runs narrower still.

09The hero scene — a finished wheel with a confetti burst and a saveable result

The instant a winner is judged, forty-two particles fire outward from the disc's center and the whole canvas becomes a downloadable image.

The hero scene — a finished wheel with a confetti burst and a saveable result
Open in a new tab

09 · The hero scene — a finished wheel with a confetti burst and a saveable result — Measured area 22.094% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramearctoDataURL
function spawnBurst(cx, cy) {
  for (var i = 0; i < 42; i++) {
    var a = rnd() * TAU, sp = 60 + rnd() * 170;
    burst.push({
      x: cx, y: cy,
      vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60,
      rot: rnd() * TAU, vr: (rnd() - 0.5) * 10,
      s: 3 + rnd() * 4,
      col: PAL[Math.floor(rnd() * PAL.length)],
      life: 1
    });
  }
}

One seed, the same burst every time

A mulberry32 generator seeded with a fixed number drives every particle's angle and speed, so this hero screen — friction, the weighted odds, and the burst all landing in one composition — reproduces its own gif capture exactly rather than drifting between runs.

What breaks when the friction integration is faked with easing

Nine specific failures show up if any of these shortcuts get taken. Faking the stop with a CSS transition's easing curve instead of integrating angularVelocity *= friction^dt every frame throws away the one property that makes a flick feel real — that a harder flick produces a measurably longer spin and a different resting angle, not the same canned deceleration replayed at one speed. Picking an arbitrary sector to display as the winner rather than deriving it from norm(-Math.PI/2 - angle) divided by the sector angle turns a physics demo into a staged animation the instant anyone checks the math. Item 04's weighted sectors are only fair if the angles on screen are the actual cumulative shares — drawing equal wedges and printing a percentage as decoration next to them is the one shortcut that would make the whole exercise dishonest, since sgvkvgus sits inside the full nine-item zip specifically so the real angle-splitting code, not a screenshot of it, is what a reader can check. Running human input and the seeded autoplay through two different functions is a quieter trap, because the two paths drift apart the moment either one gets tuned, and a dropped frame — a tab switch, a stutter — inflating an unclamped dt can make the wheel visibly jump several turns in a single tick rather than a single degree. Item 05's tick sound is written to stay silent outside a real drag, since leaking audio into the seeded loop would make a captured gif pretend to have sound it never plays for anyone watching it. Item 07 has to recompute TAU / n on every add or remove or the old sector layout quietly stops matching the new entry count, and item 08's elimination has to be undoable or a single misclick permanently breaks whatever draw it's running. Finally, showing a prefers-reduced-motion visitor an empty canvas instead of a frozen frame with a result already on it treats an accessibility signal as a reason to remove information instead of motion.

Putting these wheels to work — raffles, standups, and prize booths

An elimination bracket for a raffle where nobody should be picked twice is item 08's job — the winner drops out of the array and an undo actually reverses the draw, so a wrong click at a live event doesn't cost the organizer a re-roll from scratch. A trade-show booth handing out prizes gets more mileage from item 09's confetti burst and its toDataURL save button, since a visitor can walk away with a screenshot-worthy result instead of a recording someone else made of the moment.

A standup or classroom that changes its roster every session is what item 07's entry editor is actually for — typing a new list of names live recomputes both the sector count and the font size that fits it, so the wheel never needs a rebuild between meetings.

Three dial-ins, one accessibility fallback

Variant What changes What it trades off
Friction coefficient Item 03's friction from 0.99 down to 0.965 Lower friction spins longer before the wait feels earned; higher friction snaps to a satisfying stop but the flick's payoff shrinks — the same tuning a prize wheel online generator would expose as a settings slider
Sector count Item 01's sector count from 6 up to 18 More sectors make the ticking rhythm feel denser and faster, but the labels shrink and at-a-glance readability of names drops
Tick wobble Item 05's pointer amplitude from 2px to 7px A bigger amplitude sells the feel of a real peg catching, but pushed too far the pointer visibly drifts from the judged angle and the math starts to look wrong even though it isn't

Reduced motion, item by item

Every item checks window.matchMedia('(prefers-reduced-motion: reduce)') once at load and, when it matches, stops the animation loop entirely and renders one settled frame with a result already shown rather than an empty disc — item 06 freezes with a name already announced through aria-live, item 08 freezes with its alive and log arrays already split, and item 09 freezes past the confetti burst with the winner's name already risen, so no visitor loses the information a moving frame would otherwise carry.

Questions before wiring a spin wheel into a real page

Why does the wheel land somewhere different even when I flick it the same way twice?

Angular velocity comes from the exact dA / dt your hand produced on that particular drag, and no two flicks — even ones that feel identical — carry precisely the same delta and timing, so the resting angle genuinely differs run to run rather than being pre-decided.

Can I turn one of these into a spin wheel picker for team names?

Item 07's entry editor is built for exactly that — typing a name recomputes the sector count and refits every label's font size live, so the picker stays readable whether it holds four names or seven.

Does the tick sound play during the gif capture or the autoplay demo?

No — item 05 gates its OscillatorNode tick on drag.on, so it plays only while an actual human hand is spinning the wheel, and every autoplay loop or recorded capture of it stays intentionally silent.

Enter the archive password

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