Building a 60fps Wheel Spinner Animation with Next.js, TypeScript, and CSS Transforms

markdownSpinning wheel animations look simple. Get one to feel right — smooth at 60fps on every device, physics-based deceleration that builds genuine suspense, and a landing position mathematically tied to a cryptographic random value — and you're solving a surprisingly deep set of problems.
At WheelieNames, our wheel spinner handles 10M+ spins for 500K+ monthly users. Here's how we built the animation system.
The Requirements
Before writing a line of code, we defined what "right" means for a wheel spinner:
60fps on all devices — including budget Android phones and classroom Chromebooks
Physics-based deceleration — the wheel slows naturally, not abruptly
Animation-outcome unity — the rotation angle is calculated from the random winner, not the other way around
Cryptographic fairness — Web Crypto API, not Math.random()
Touch support — tap-to-spin on mobile with satisfying haptic-style feedback
Fullscreen projection — teachers project this on classroom screens
If any of these fail, the tool fails. A janky animation undermines trust. An abrupt stop looks fake. A disconnect between animation and outcome is fake.
Architecture: CSS Transforms + JavaScript Physics
We chose CSS transform: rotate() with JavaScript-controlled transitions over Canvas or SVG animation for one critical reason: GPU compositing.
CSS transforms with will-change: transform are handled by the browser's compositor thread, not the main thread. This means the animation continues smoothly even if JavaScript is busy processing other tasks. Canvas and SVG animations run on the main thread and compete with layout, paint, and JavaScript execution.
// The wheel element uses CSS for GPU-accelerated rotation
interface WheelAnimationConfig {
element: HTMLElement;
totalDegrees: number;
durationMs: number;
easingFunction: string;
}
function animateWheel(config: WheelAnimationConfig): Promise {
const { element, totalDegrees, durationMs, easingFunction } = config;
return new Promise((resolve) => {
// Hint browser to optimize this property
element.style.willChange = 'transform';
// Apply transition and rotation in the same frame
requestAnimationFrame(() => {
element.style.transition = `transform \({durationMs}ms \){easingFunction}`;
element.style.transform = `rotate(${totalDegrees}deg)`;
});
// Clean up after animation completes
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto';
resolve();
}, { once: true });
});
}
The Easing Curve
This is where physics meets perception. A linear deceleration looks robotic. A standard ease-out feels too smooth — like someone gently pressed pause. A real wheel has friction, momentum, and a moment where it almost stops on one name before creeping to the next.
We use a custom cubic-bezier curve:
cubic-bezier(0.17, 0.67, 0.12, 0.99)
Breaking this down:
0.17, 0.67 — fast initial acceleration in the first ~10% of the animation. The wheel launches with energy.
0.12, 0.99 — long, drawn-out deceleration that reaches ~99% of the final position by about 70% of the duration. The last 30% is a slow, tension-building creep.
The effect: viewers lean in during the final seconds. The wheel passes one name... slows down... almost stops on another... and finally settles. This creates genuine suspense that a linear or standard ease-out can't match.
// Easing comparison for 4-second spin
const easingOptions = {
// ❌ Linear — boring, robotic
linear: 'linear',
// ❌ Standard ease-out — too smooth, no tension
easeOut: 'ease-out',
// ❌ Aggressive ease-out — stops too suddenly
aggressive: 'cubic-bezier(0, 0, 0.2, 1)',
// ✅ WheelieNames custom — physics-based with suspense
wheelie: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
};
Calculating Rotation from Random Value
The winner is determined by the Web Crypto API. The rotation angle is derived from that winner. Not the other way around.
function calculateSpinRotation(
entries: string[],
winnerIndex: number
): { totalDegrees: number; duration: number } {
const segmentAngle = 360 / entries.length;
// Center of the winning segment
const winnerCenterAngle = segmentAngle * winnerIndex + (segmentAngle / 2);
// Random offset within the segment (also crypto-random)
const offsetArray = new Uint32Array(1);
crypto.getRandomValues(offsetArray);
const maxOffset = segmentAngle * 0.35; // Stay within inner 70% of segment
const offset = ((offsetArray[0] / 0xFFFFFFFF) * maxOffset * 2) - maxOffset;
// Landing angle (where the pointer will be)
const landingAngle = 360 - (winnerCenterAngle + offset);
// Add full rotations for visual effect (5-10 full spins)
const spinCountArray = new Uint32Array(1);
crypto.getRandomValues(spinCountArray);
const fullSpins = 5 + (spinCountArray[0] % 6); // 5 to 10 full rotations
const totalDegrees = (fullSpins * 360) + landingAngle;
// Duration scales with rotation count
const duration = 3000 + (fullSpins * 300); // 4.5s to 6s
return { totalDegrees, duration };
}
Key decisions:
Stay within inner 70% of the segment — the pointer should clearly be on the winning name, not hovering ambiguously at the border
5-10 full rotations — enough spins for the deceleration curve to create suspense, not so many that viewers get bored
3-6 second duration — the sweet spot between "too fast to enjoy" and "too slow to care"
Segment Rendering with Dynamic Colors
The wheel segments are rendered as conic gradients — one of the most underused CSS features:
function generateWheelBackground(
entries: string[],
theme: ColorTheme
): string {
const segmentAngle = 360 / entries.length;
const colors = theme.colors;
const segments = entries.map((_, index) => {
const startAngle = segmentAngle * index;
const endAngle = startAngle + segmentAngle;
const color = colors[index % colors.length];
return `\({color} \){startAngle}deg ${endAngle}deg`;
});
return `conic-gradient(from 0deg, ${segments.join(', ')})`;
}
// Apply to wheel element
wheelElement.style.background = generateWheelBackground(entries, selectedTheme);
Six color themes are available, each designed for different environments:
const themes: Record = {
classic: {
name: 'Classic Wheel',
colors: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40']
},
vibrant: {
name: 'Vibrant Wheel',
colors: ['#E74C3C', '#3498DB', '#2ECC71', '#F39C12', '#9B59B6', '#1ABC9C']
},
pastel: {
name: 'Pastel',
colors: ['#FFB3BA', '#BAE1FF', '#BAFFC9', '#FFFFBA', '#E8BAFF', '#FFE4BA']
},
ocean: {
name: 'Ocean',
colors: ['#006994', '#0099CC', '#00CED1', '#20B2AA', '#48D1CC', '#40E0D0']
},
gradient: {
name: 'Modern Gradient',
colors: ['#667EEA', '#764BA2', '#F093FB', '#F5576C', '#4FACFE', '#43E97B']
},
corporate: {
name: 'Corporate Blue',
colors: ['#1A365D', '#2B6CB0', '#3182CE', '#4299E1', '#63B3ED', '#90CDF4']
}
};
Fullscreen Mode for Classrooms
Teachers project WheelieNames on classroom screens. Fullscreen mode needs to be seamless:
async function toggleFullscreen(wheelContainer: HTMLElement): Promise {
if (!document.fullscreenElement) {
try {
await wheelContainer.requestFullscreen();
// Scale wheel to fill viewport
wheelContainer.classList.add('fullscreen-mode');
} catch (err) {
// Fallback: maximize within viewport
wheelContainer.classList.add('pseudo-fullscreen');
}
} else {
await document.exitFullscreen();
wheelContainer.classList.remove('fullscreen-mode', 'pseudo-fullscreen');
}
}
.fullscreen-mode {
display: flex;
align-items: center;
justify-content: center;
background: #1a1a2e;
/* Wheel scales to 80vh for comfortable viewing */
}
.fullscreen-mode .wheel {
width: min(80vh, 80vw);
height: min(80vh, 80vw);
}
The fallback for browsers that restrict the Fullscreen API (some school-managed Chromebooks) uses a CSS-only approach that maximizes the wheel within the viewport.
Confetti Celebration
When a winner is selected, confetti particles burst from the center of the wheel. We use lightweight CSS animations instead of a heavy library:
function createConfetti(container: HTMLElement, count: number = 50): void {
const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF'];
for (let i = 0; i < count; i++) {
const particle = document.createElement('div');
particle.className = 'confetti-particle';
const color = colors[Math.floor(Math.random() * colors.length)];
const angle = (Math.random() * 360) * (Math.PI / 180);
const velocity = 200 + Math.random() * 300;
const x = Math.cos(angle) * velocity;
const y = Math.sin(angle) * velocity;
particle.style.cssText = `
--x: ${x}px;
--y: ${y}px;
--rotation: ${Math.random() * 720}deg;
background: ${color};
`;
container.appendChild(particle);
// Clean up after animation
particle.addEventListener('animationend', () => particle.remove());
}
}
Note: confetti uses Math.random() — not the crypto API. Confetti color distribution doesn't need to be cryptographically secure.
Performance Results
Across all devices we've tested:
| Device | FPS During Spin | Time to Interactive |
|---|---|---|
| MacBook Pro (M2) | 60fps | < 0.5s |
| iPhone 14 | 60fps | < 0.6s |
| Samsung Galaxy A14 (budget) | 58-60fps | < 1.0s |
| Classroom Chromebook | 55-60fps | < 1.2s |
| iPad Air | 60fps | < 0.5s |
The CSS transform approach pays off on budget devices — the compositor thread handles the rotation while the main thread stays free for UI interactions.
Key Takeaways
CSS transforms > Canvas for single-element animation. GPU compositing gives you 60fps for free on most devices.
Custom cubic-bezier curves create emotion. The deceleration profile is the difference between a boring tool and a suspenseful experience.
Tie animation to outcome mathematically. If the rotation angle is derived from the random value, there's no gap for manipulation.
Conic gradients are underused. Perfect for pie charts, wheel segments, and circular UIs.
Fullscreen mode needs fallbacks. School-managed browsers often restrict the API.
Links:
🌐 WheelieNames 📚 Blog & Guides 🛒 App Store 📂 GitHub 🚀 Product Hunt 📊 Crunchbase ✍️ Medium 💻 Dev.to 📺 YouTube
*İsmail Günaydın — Founder of WheelieNames. Full-stack web engineer building privacy-

