Most "random" wheel spinners online aren't really random. They use Math.random(), which is a pseudorandom number generator — predictable, seedable, and manipulable. For a toy, that's fine. For picking a giveaway winner in front of 10,000 viewers, or selecting which student answers a question in a classroom of 30, it's not.
At WheelieNames, we use the Web Crypto API for every spin. Here's why that matters and how we implemented it.
The Problem with Math.random()
JavaScript's Math.random() is a PRNG (Pseudorandom Number Generator). It generates numbers that appear random but are determined by an internal state. In many engines, if you know the state, you can predict every future value.
// This is NOT secure randomness
const winner = Math.floor(Math.random() * names.length);
// Predictable. Seedable. Manipulable.
For most web applications, this is perfectly adequate. For a random selection tool where fairness is the entire point — where teachers, event hosts, and teams need to trust the result — it's insufficient.
Enter the Web Crypto API
The Web Crypto API provides crypto.getRandomValues(), which generates cryptographically strong random values. The W3C specification states that implementations must use a source of entropy suitable for cryptographic operations.
This is the same API used by:
- HTTPS/TLS connections
- Banking applications
- Password generators
- UUID generation
- Cryptocurrency wallets
If it's secure enough for your bank, it's secure enough for a classroom raffle.
Our Implementation
Here's a simplified version of how WheelieNames generates a fair random selection:
// Cryptographically secure random index selection
function getSecureRandomIndex(max: number): number {
// Create a typed array for the random value
const array = new Uint32Array(1);
// Fill with cryptographically secure random value
crypto.getRandomValues(array);
// Convert to index within our range
// Using modulo with rejection sampling for uniform distribution
const randomValue = array[0];
return randomValue % max;
}
// Usage in the wheel
function spinWheel(entries: string[]): string {
const winnerIndex = getSecureRandomIndex(entries.length);
return entries[winnerIndex];
}
But there's a subtle issue with the naive modulo approach: if max doesn't evenly divide 2^32, some indices have a slightly higher probability. For true uniformity, we use rejection sampling:
function getUniformRandomIndex(max: number): number {
const limit = Math.floor(0xFFFFFFFF / max) * max;
let value: number;
do {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
value = array[0];
} while (value >= limit); // Reject values that cause bias
return value % max;
}
This guarantees every entry has exactly equal probability — not approximately equal, not "close enough," but mathematically equal.
The Animation-Outcome Unity Problem
Here's where most wheel spinners cheat, even unintentionally: they determine the winner with one piece of code, then run a separate animation that lands on that winner. The animation is theater — it has nothing to do with the selection.
This creates a trust problem. If the animation and the outcome are separate systems, there's an invisible layer where manipulation could happen. Even if the developer has no intention of cheating, the architecture allows cheating, and a skeptical audience has no way to verify.
WheelieNames solves this differently:
interface SpinResult {
winnerIndex: number;
totalRotation: number; // degrees
duration: number; // milliseconds
}
function calculateSpin(entries: string[]): SpinResult {
const winnerIndex = getUniformRandomIndex(entries.length);
// Calculate the angle that lands on this winner
const segmentAngle = 360 / entries.length;
const winnerAngle = segmentAngle * winnerIndex;
// Add random offset within the winning segment
const offsetArray = new Uint32Array(1);
crypto.getRandomValues(offsetArray);
const segmentOffset = (offsetArray[0] / 0xFFFFFFFF) * segmentAngle * 0.8;
// Total rotation: multiple full spins + landing angle
const fullSpins = 5 + Math.floor((offsetArray[0] % 5));
const totalRotation = (fullSpins * 360) + winnerAngle + segmentOffset;
return {
winnerIndex,
totalRotation,
duration: 3000 + (fullSpins * 200)
};
}
The key insight: the totalRotation value is derived directly from winnerIndex, which is derived from the cryptographic random value. The animation rotates the wheel by exactly totalRotation degrees using CSS transforms. When the wheel stops, it's pointing at the winner — because the rotation angle was calculated to land there.
One random value → one winner → one rotation angle → one animation. No separate systems. No hidden layer.
Physics-Based Deceleration
A wheel that stops abruptly looks fake. We use an easing function that mimics real physics:
// CSS animation with cubic-bezier easing
function applySpinAnimation(
wheelElement: HTMLElement,
totalRotation: number,
duration: number
) {
wheelElement.style.transition = `transform ${duration}ms cubic-bezier(0.17, 0.67, 0.12, 0.99)`;
wheelElement.style.transform = `rotate(${totalRotation}deg)`;
}
The cubic-bezier(0.17, 0.67, 0.12, 0.99) curve produces:
- Fast initial acceleration
- Gradual, realistic deceleration
- A satisfying "almost stopped, still creeping" moment near the end
- Natural final stop without bounce
This builds genuine suspense. The audience leans in during the deceleration, watches it slow down, and experiences the reveal naturally — just like a physical wheel.
Local-First Data Architecture
Privacy isn't a feature we bolt on. It's a structural decision that eliminates entire categories of problems:
// All data operations use localStorage only
const STORAGE_KEY = 'wheelienames_wheels';
function saveWheel(wheel: WheelConfig): void {
const wheels = getWheels();
wheels[wheel.id] = wheel;
localStorage.setItem(STORAGE_KEY, JSON.stringify(wheels));
// Nothing leaves the browser. Ever.
}
function getWheels(): Record<string, WheelConfig> {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : {};
}
Benefits of this approach:
- No server costs for user data storage
- No data breaches — we can't leak data we don't have
- No GDPR headaches — no personal data processing to declare
- Instant performance — no network requests for data operations
- Offline capability — the wheel works without internet after initial load
Mobile Touch Implementation
63% of traffic is mobile, and touch interaction needs special attention for a spinning wheel:
// Touch-to-spin with velocity detection
let touchStartAngle = 0;
let touchStartTime = 0;
wheelElement.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
touchStartAngle = getAngleFromCenter(touch.clientX, touch.clientY);
touchStartTime = Date.now();
});
wheelElement.addEventListener('touchend', (e) => {
const touch = e.changedTouches[0];
const endAngle = getAngleFromCenter(touch.clientX, touch.clientY);
const elapsed = Date.now() - touchStartTime;
// Only trigger spin if swipe was fast enough
if (elapsed < 300 && Math.abs(endAngle - touchStartAngle) > 10) {
triggerSpin(); // Uses crypto random, not swipe velocity
}
});
Important: the touch gesture triggers the spin, but the winner is always determined by the crypto random function — never by swipe speed or direction. The gesture is purely an input trigger.
Results
Since launch:
- 10M+ spins completed
- 500K+ monthly users
- 50K+ teachers using it in classrooms
- Zero data breaches (can't breach what you don't store)
- Zero "rigged" complaints that survived scrutiny
Try It
Open WheelieNames, add some names, and spin. Then open DevTools → Network tab and verify: zero POST requests. Open Console and run crypto.getRandomValues(new Uint32Array(1)) — that's the same API powering every spin.
Links:
🌐 WheelieNames
📚 Blog & Guides
🛒 App Store
❓ FAQ
📂 GitHub
🚀 Product Hunt
📊 Crunchbase
✍️ Medium
📺 YouTube
İsmail Günaydın — Founder of WheelieNames. Full-stack web engineer, 15+ years of experience. Building privacy-first tools for education and live events. LinkedIn · Portfolio

Top comments (0)