Group decisions are where friendships go to stall. "Where do we eat?" "Who goes first?" "Who does the dishes?" — everyone has a preference, nobody wants to be the decider. So I built a small spinning wheel that takes the call: type a question, spin, get YES or NO. Three seconds, zero arguments.
The spin animation is the fun part. The interesting part — the part that actually matters if anyone's running a giveaway — is how the wheel picks.
Disclosure: I built this tool at toolkitloop.com/yes-no-wheel. It's one of 30 free client-side tools I maintain. The code patterns below are general; steal them.
The naive version everyone writes
The first implementation of any wheel looks like this:
const segments = ["YES", "NO"];
function spin() {
return segments[Math.floor(Math.random() * segments.length)];
}
Math.random() is fine for picking lunch. But it has two properties that should make you pause before using it for anything people care about:
- It's not cryptographically secure. The sequence is deterministic given the internal state. Nobody's going to reverse-engineer your lunch wheel, but "provably fair" giveaway draws deserve better.
-
Modulo bias.
Math.floor(Math.random() * n)is almost uniform, but with certain ranges the distribution isn't perfectly even. For a 2-segment wheel the effect is negligible; for a 47-entrant giveaway wheel, tiny biases are exactly the kind of thing a sore loser will (correctly) point at.
The fair version: crypto.getRandomValues + rejection sampling
The Web Crypto API gives us real entropy from the OS. The trick is converting random bytes into a uniform index without modulo bias — that's what rejection sampling does:
// Unbiased random index in [0, n) using Web Crypto
function fairIndex(n) {
const max = 0xffffffff;
// Largest multiple of n that fits in 32 bits — reject anything above it
const limit = max - (max % n);
const buf = new Uint32Array(1);
let x;
do {
crypto.getRandomValues(buf);
x = buf[0];
} while (x >= limit);
return x % n;
}
function spinWheel(segments) {
return segments[fairIndex(segments.length)];
}
The loop rejects the leftover range that would make some indices slightly more likely, then maps the remainder uniformly. The expected number of iterations is barely above 1 — the do...while almost never repeats. This is a ~15-line function, runs entirely client-side, and gives you a draw you can defend.
Separating selection from animation
One design decision that made the code much cleaner: the animation is theater; the selection is math. The wheel's final resting angle is computed from the already-chosen index, and the spin animation just plays out to that angle:
// Selection happens first; animation follows
const winnerIndex = fairIndex(segments.length);
const sliceAngle = 360 / segments.length;
// Land the pointer in the middle of the winning slice,
// after a few full rotations for drama
const finalAngle = 360 * 5 + (winnerIndex * sliceAngle) + sliceAngle / 2;
wheel.style.transform = `rotate(${finalAngle}deg)`;
This separation has a nice side effect: you can unit-test fairness (run 100k spins, check the distribution) without touching any animation code. If you're building any kind of picker, test the distribution — chi-square is overkill, but eyeballing a histogram of 10k draws catches real bugs.
Everything stays on the device
The whole thing — entropy, selection, rendering — happens in the browser. No server round-trip, no list of entrant names uploaded anywhere. For a giveaway tool this isn't just a nice privacy story; it removes an entire class of "what did you do with my email list?" questions.
Try it / take it apart
The tool is free and needs no account: toolkitloop.com/yes-no-wheel — yes/no mode for quick calls, custom segments for name wheels and giveaways.
Question for the comments: have you ever caught Math.random()-style bias causing a real problem — a lopsided A/B test, a "random" shuffle that wasn't, a giveaway dispute? I'd love to hear the war stories. 👇
Top comments (0)