Spin the Wheel HTML CSS JS — Canvas Prize Wheel

Spin the Wheel · Animations · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Canvas 2D arc: ctx.arc(cx, cy, r, startAngle, endAngle) per segment, text at 60% radius along midpoint angle
requestAnimationFrame easing: quartic ease-out 1-Math.pow(1-t,4) over 4800ms — physics-feel deceleration
Random target: spinCount(4-6) full rotations + randomOffset for landing position — different winner every spin
Web Audio tick: createOscillator 880Hz + exponentialRampToValueAtTime 0.04s fade — no audio files needed
Segment change detection: currentSegment computed per frame, tick fires only on segment boundary crossing
Winner computation: (2π - normalizedRotation) / segmentAngle gives index of segment under 12 o'clock pointer
CSS pointer triangle: border trick (transparent left/right, colored top border) — downward pointing arrow
Dynamic item editor: items array + renderItemList() + drawWheel() called on add/remove for instant update
Responsive canvas: Math.min(300, innerWidth-48) sizing, canvas.width=height=size on window resize

About this UI Snippet

Spin the Wheel — How to Build an Animated Canvas Prize Wheel with Web Audio Tick Sounds in JavaScript

Screenshot of the Spin the Wheel snippet rendered live

A spin-the-wheel randomizer is one of the most kinetically satisfying UI components you can build — the smooth deceleration, the ticking sound on each segment, and the winner reveal all feel rewarding in a way that a random number generator does not. Building it correctly requires mastering four distinct browser technologies: Canvas 2D for drawing the wheel, requestAnimationFrame for smooth animation, the Web Audio API for the tick sounds, and pointer events for the spin interaction.

This snippet builds a complete, fully interactive prize wheel with editable items, dynamic segment drawing, requestAnimationFrame-based easing, Web Audio tick sounds, and a winner overlay — all in plain JavaScript with no external libraries.

Canvas 2D Arc Drawing

The wheel is drawn on an HTML <canvas> element using the Canvas 2D API. Each prize segment is a pie slice drawn with ctx.beginPath(), ctx.moveTo(cx, cy), ctx.arc(cx, cy, radius, startAngle, endAngle), ctx.closePath(). The arc command takes angles in radians: startAngle = rotation + i * segmentAngle, endAngle = startAngle + segmentAngle, where segmentAngle = 2 * Math.PI / items.length.

The fill color cycles through the COLORS array by index: ctx.fillStyle = COLORS[i % COLORS.length]. After filling, the text label is drawn: ctx.save(), translate to the arc midpoint, rotate to align with the segment, ctx.fillText(item), ctx.restore(). The text position: translate to (cx + cos(midAngle) * (radius * 0.6), cy + sin(midAngle) * (radius * 0.6)) — 60% of the way from center to edge along the segment midpoint angle.

The entire drawWheel(rotation) function is called on every animation frame, passing the current rotation offset. This re-draws all segments at the new angle, creating the animation.

The Pointer Triangle

A CSS triangle (<div class="pointer">) sits at the top of the wheel and marks the winning segment. It uses the border trick: a zero-width/height div with border-left: 14px solid transparent; border-right: 14px solid transparent; border-top: 24px solid #fff creates a downward-pointing triangle. The pointer is purely CSS — no SVG, no canvas.

The winning segment is determined by which segment is under the pointer at the end of the spin. Since the pointer is at the 12 o'clock position (−90° = top of canvas), the winning segment is: ((2 * Math.PI - (finalRotation % (2 * Math.PI))) / segmentAngle) rounded to get the segment index.

requestAnimationFrame Easing

The spin animation uses a cubic ease-out deceleration. A random target rotation is computed: targetRotation = rotation + (Math.PI * 2 * spinCount) + randomOffset where spinCount is 4–6 full rotations and randomOffset is a random fraction of a full rotation. This ensures the wheel spins multiple times before landing on a random segment.

On each animation frame: t = (now - startTime) / DURATION (where DURATION is 4800ms). The easing: ease = 1 - Math.pow(1 - t, 4) — a quartic ease-out that starts fast and decelerates dramatically near the end, simulating physical wheel inertia. currentRotation = startRotation + (targetRotation - startRotation) * ease. When t >= 1, the animation stops, the final rotation is set, and the winner is computed.

Web Audio API Tick Sounds

The tick sound plays each time the pointer crosses a new segment during the spin. An AudioContext is created lazily on first user interaction (required by browser autoplay policy). On each animation frame, the current segment under the pointer is computed: currentSegment = Math.floor((2*Math.PI - normalizedRotation) / segmentAngle) % items.length. If currentSegment !== lastTickSegment, a tick plays and lastTickSegment updates.

The tick is synthesized with the Web Audio API: const osc = audioCtx.createOscillator(), osc.frequency.value = 880 (Hz), osc.connect(gainNode), gainNode.gain.setValueAtTime(0.15, now), gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.04), osc.start(), osc.stop(now + 0.05). This creates a brief 880Hz click that naturally fades out — no audio files, no WAV/MP3 loading required.

The tick frequency increases as the wheel decelerates: early in the spin, segments flash past quickly and ticks blend together. As the wheel slows, individual ticks become audible. This matches the physics of a real ratchet wheel.

Dynamic Segment Count and Item Editor

The wheel dynamically redraws when items are added or removed. Items are stored in a JavaScript array. renderItemList() creates a <li> element for each item with a delete button. drawWheel() always reads items.length to compute segmentAngle and cycles through COLORS by index.

Adding an item: the Add button reads the input value, validates it (non-empty, under max length), pushes it to the array, calls renderItemList() and drawWheel(rotation). Removing an item: the delete button calls items.splice(index, 1) and re-renders. The wheel instantly shows the updated segments with equal-width arcs.

Responsive Canvas Sizing

The canvas is resized on load and window.resize via resize(): const size = Math.min(300, window.innerWidth - 48). This caps the canvas at 300px and reduces it on narrow screens. canvas.width = canvas.height = size sets the pixel buffer, and canvas.style.width = canvas.style.height = size + "px" sets the CSS display size to match (1:1 pixel density by default, higher on devicePixelRatio screens if scaled).

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to work out the winner-before-animation-starts logic by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the target rotation (extraRotations plus winnerAngle) is calculated once at the start of spin() rather than the winner being read off wherever the wheel happens to stop, or how getSegmentAtAngle compares the current animation frame's rotation against lastTickSegment to know exactly when to fire a Web Audio tick. The same assistant can help optimize it, for instance checking whether drawWheel's full clearRect-and-redraw-every-segment approach on every requestAnimationFrame call would still hit 60fps with thirty or forty wheel items instead of eight. It is just as useful for extending the wheel: ask it to add per-item weighted odds so some prizes are statistically more likely without changing their visual segment size, persist the item list to localStorage across reloads, or add a confetti burst timed to the exact moment the result overlay appears. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a canvas-based "spin the wheel" prize randomizer in plain HTML, CSS, and JavaScript using the Canvas 2D API, requestAnimationFrame, and the Web Audio API — no libraries, no external audio files.

Requirements:
- A canvas wheel divided into equal arc segments (one per item in a JavaScript array), each drawn with ctx.arc using a shared center and radius, filled with a distinct color cycling through a fixed palette, with the item's label drawn rotated to align along that segment's midpoint angle.
- A fixed CSS-only triangle pointer (built from transparent side borders and a solid top border, not an image or SVG) positioned at the top center of the wheel, marking the winning segment.
- When the spin button is clicked: pick a random winning item index BEFORE the animation starts, compute the exact total rotation delta needed to land that segment under the top pointer (several full extra rotations for visual flourish, plus the precise angular offset to the winning segment, with a small random offset within that segment so it doesn't always land dead-center), and store that as a fixed target — the visual animation must never determine the outcome; the outcome is decided before the first frame renders.
- Animate the wheel's rotation from its current value to that pre-computed target over a fixed duration (several seconds) using requestAnimationFrame and a cubic (or quartic) ease-out timing function, so the spin starts fast and decelerates smoothly to a stop like a physical wheel with friction.
- On every animation frame, determine which segment currently sits under the pointer, and each time that segment index changes from the previous frame, synthesize and play a very short percussive tick sound using a Web Audio oscillator node with an exponential gain ramp down to near-silence, with the AudioContext created lazily on the first user gesture (not on page load).
- After the animation completes, display the pre-determined winning item's label in a result overlay, and provide UI to add and remove wheel items live, redrawing the wheel with correctly re-divided equal segments whenever the item count changes (refusing to spin below two items).

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Spin the wheelClick the SPIN button. The wheel accelerates, spins through 4–6 full rotations with a ticking sound on each segment, then decelerates and stops on a random winner.
  2. 2
    See the winnerA result overlay appears showing the winning item. Click Dismiss to close it and spin again.
  3. 3
    Add itemsType a new item in the input field below the wheel and click Add (or press Enter). The wheel redraws immediately with an additional equal-width segment.
  4. 4
    Remove itemsClick the × button next to any item in the list to remove it. The wheel redraws with one fewer segment. Minimum 2 items required to spin.
  5. 5
    Enable soundThe tick sound uses Web Audio API and requires a user gesture. Click SPIN once to unlock the AudioContext. Subsequent spins play the tick automatically.
  6. 6
    Customize the wheelEdit the COLORS array to change segment colors. Edit the initial items array for default options. Change DURATION for faster or slower spins.

Real-world uses

Common Use Cases

Giveaway & Prize Draw Tools
Run live prize draws on streams, at events, or in marketing campaigns. Enter participant names, spin, and the winner is selected fairly at random. The animation and sound make the reveal feel exciting and dramatic rather than just picking a number.
Classroom Random Selector & Ice Breakers
Teachers use random selectors to call on students fairly, assign random topics for presentations, or pick teams. The spin animation adds playful drama to otherwise dry classroom administration. Add student names to the wheel and spin for cold calling.
Decision Maker & Random Choice Tool
Can't decide where to eat, what movie to watch, or which task to tackle first? Spin the wheel. The wheel works for any binary or multi-option decision. The forced random choice removes decision fatigue for low-stakes choices.
Gamified Onboarding Rewards & Loyalty Programs
Reward users with a spin-the-wheel moment after completing onboarding, making a purchase, or hitting a milestone. The random prize (discount, bonus, free item) creates excitement and reinforces the desired behavior. Connect the winner result to your backend rewards system.
Canvas 2D & Web Audio API Study Reference
Study the complete implementation of Canvas 2D arc drawing, requestAnimationFrame animation with easing, Web Audio API oscillator synthesis, and segment crossing detection. These four browser APIs appear across games, data visualizations, and interactive media — this is a compact working example of all four together.
Interactive Event & Conference Engagement
Use at trade show booths, conference sessions, or product launch events. Attendees input their names, spin for a prize, and the dramatic deceleration animation makes the reveal memorable. The item editor lets event staff customize prizes on the fly without code changes.

Got questions?

Frequently Asked Questions

A random target rotation is calculated before the spin starts: startRotation + full rotations + random landing offset. On each animation frame, t = (elapsed / DURATION) gives progress 0–1. The ease = 1 - Math.pow(1-t, 4) quartic ease-out maps that linear progress to a curve that moves quickly at the start and extremely slowly at the end. currentRotation = startRotation + (targetRotation - startRotation) * ease. The deceleration near t=1 creates the "slowing to a stop" feel. Because the target rotation is fixed before the animation starts, the winner is always deterministic — the easing only affects the visual path, not the outcome.

An OscillatorNode is created: const osc = audioCtx.createOscillator(); osc.type = "sine"; osc.frequency.value = 880. A GainNode applies a fast volume envelope: gainNode.gain.setValueAtTime(0.15, now) sets the initial volume, and gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.04) fades it to near-zero over 40ms. osc.start(now); osc.stop(now + 0.05) plays a 50ms burst. The AudioContext is created lazily on first user interaction to comply with browser autoplay policy — iOS and Chrome require a gesture before creating an AudioContext.

After the spin stops, finalRotation is the total accumulated rotation in radians. normalizedRotation = ((finalRotation % (2*Math.PI)) + 2*Math.PI) % (2*Math.PI) normalizes it to 0–2π. The pointer is at the top (12 o'clock = -π/2). The segment under the pointer: segmentIndex = Math.floor((2*Math.PI - normalizedRotation + Math.PI/2) / segmentAngle) % items.length. The winner is items[segmentIndex]. The exact formula depends on where segment 0 starts in your drawing code.

Check items.length before spinning: if (items.length < 2) { alert("Add at least 2 items to spin"); return; }. For the delete button, check after splice: if (items.length < 1) { items.push("Default"); }. You can also disable the SPIN button via spinBtn.disabled = items.length < 2 and re-enable it in renderItemList() after adds. Style the disabled state with CSS: .spin-btn:disabled { opacity: 0.5; cursor: not-allowed; }.

After any add or remove operation, call: localStorage.setItem("wheel-items", JSON.stringify(items)). On page load: const saved = localStorage.getItem("wheel-items"); if (saved) { items = JSON.parse(saved); } else { items = defaultItems; }. Call renderItemList() and drawWheel(0) after loading. This persists the item list across refreshes. For multi-user or multi-device persistence, save to your backend API instead.

Yes. Click JSX for React, Vue for a Vue 3 SFC, Angular for a standalone component, or Tailwind for a utility-class build. In React, store the accumulated rotation in a ref (not state) so the CSS transition animates from the previous angle, and read the winning segment in a transitionend handler.