Interactive
SpinWheel
A fairground wheel in enamel paint, with brass pegs, a leather flipper and a ring of bulbs. Flick it or press the hub; the pegs slow it, the flipper holds it back at the end, and where it stops is the prize. Enter works too.
Download
- React
- Tailwind CSS v4
- motion
components/ui/spin-wheel.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type SpinWheelProps = {
/** Prizes round the wheel, 3 or more. */
items?: string[];
/** Enamel colours, used in turn. */
colors?: string[];
/** Tick on each peg. */
sound?: boolean;
/** Called with the winning item and its index. */
onResult?: (item: string, index: number) => void;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const SIZE = 360;
const CX = 180;
const CY = 188;
const R = 146; // Painted face.
const HUB = 46;
const BULBS = 24;
const TAU = Math.PI * 2;
const ITEMS = ["Chai", "Vadai", "Try again", "Bajji", "Jackpot", "Sundal", "Murukku", "Free refill"];
const COLORS = ["#c2342b", "#f0b43c", "#1b7a6e", "#f3e4c4"];
const dark = (hex: string) => {
const v = parseInt(hex.slice(1), 16);
return 0.299 * (v >> 16) + 0.587 * ((v >> 8) & 255) + 0.114 * (v & 255) < 150;
};
const polar = (r: number, a: number) => [CX + r * Math.cos(a), CY + r * Math.sin(a)];
const clamp = (v: number, m: number) => Math.max(-m, Math.min(m, v));
type Engine = { spin: (speed: number) => void; grab: (x: number, y: number) => void; drag: (x: number, y: number) => void; drop: () => void };
/* SpinWheel: a fairground wheel in enamel paint, with brass pegs, a leather flipper and a ring of bulbs. Flick it or press the hub; the pegs slow it, the flipper holds it back at the end, and where it stops is the prize. */
export function SpinWheel({ items = ITEMS, colors = COLORS, sound = true, onResult, label = "Prize wheel", className = "" }: SpinWheelProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const svg = useRef<SVGSVGElement>(null);
const wheel = useRef<SVGGElement>(null);
const flipper = useRef<SVGGElement>(null);
const engine = useRef<Engine | null>(null);
const callback = useRef(onResult);
const [spinning, setSpinning] = useState(false);
const [win, setWin] = useState(-1);
const n = items.length;
const slice = TAU / n;
useEffect(() => {
callback.current = onResult;
}, [onResult]);
useEffect(() => {
const zone = slice * 0.15; // Near a peg, the flipper pushes back.
let angle = slice / 2;
let speed = 0;
let live = false; // A proper spin, so its stop counts.
let held = false;
let hand = 0;
let handSpeed = 0;
let handTime = 0;
let flip = 0;
let lastPeg = Math.floor(angle / slice);
let lastTick = 0;
let raf = 0;
let then = 0;
let audio: AudioContext | undefined;
const peg = () => {
const f = ((-angle % slice) + slice) % slice;
return f < slice / 2 ? f : f - slice;
};
const paint = () => {
wheel.current?.setAttribute("transform", `rotate(${((angle * 180) / Math.PI).toFixed(2)} ${CX} ${CY})`);
const d = peg();
const bend = Math.abs(d) < zone ? (d < 0 ? 1 : -1) * (1 - Math.abs(d) / zone) * 30 : 0;
flip += (bend - flip) * 0.55;
flipper.current?.setAttribute("transform", `rotate(${flip.toFixed(1)} ${CX} 6)`);
};
const click = (now: number) => {
if (!audio || now - lastTick < 34) return;
lastTick = now;
const t = audio.currentTime;
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = "square";
osc.frequency.value = 1500;
gain.gain.setValueAtTime(0.045, t);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);
osc.connect(gain).connect(audio.destination);
osc.start(t);
osc.stop(t + 0.035);
};
const finish = () => {
setSpinning(false);
if (!live) return;
live = false;
const i = Math.floor((((-angle % TAU) + TAU) % TAU) / slice) % n;
setWin(i);
callback.current?.(items[i], i);
};
const tick = (now: number) => {
const dt = Math.min(0.033, (now - then) / 1000 || 0.016);
then = now;
if (!held) {
const steps = Math.max(1, Math.ceil(Math.abs(speed) / 8));
for (let s = 0; s < steps; s++) {
const h = dt / steps;
const d = peg();
const dir = speed === 0 ? 0 : Math.sign(speed);
speed -= (dir * 0.8 + speed * 0.3) * h;
if (Math.sign(speed) !== dir) speed = 0;
if (Math.abs(d) < zone) speed -= (d < 0 ? -1 : 1) * 22 * (1 - Math.abs(d) / zone) * h;
angle += speed * h;
}
}
const pegNow = Math.floor(angle / slice);
if (pegNow !== lastPeg) {
lastPeg = pegNow;
click(now);
}
paint();
if (!held && Math.abs(speed) < 0.012 && Math.abs(peg()) >= zone) {
speed = 0;
raf = 0;
flipper.current?.setAttribute("transform", `rotate(0 ${CX} 6)`);
finish();
return;
}
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (raf) return;
then = performance.now();
raf = requestAnimationFrame(tick);
};
const wakeSound = () => {
if (!sound) return;
audio ??= new AudioContext();
void audio.resume();
};
const pointAngle = (x: number, y: number) => Math.atan2(y - CY, x - CX);
engine.current = {
spin(start) {
if (live) return;
wakeSound();
setWin(-1);
live = true;
if (reduce) {
angle = -(Math.floor(Math.random() * n) + 0.5) * slice;
paint();
finish();
return;
}
setSpinning(true);
speed = start;
wake();
},
grab(x, y) {
if (live) return;
wakeSound();
held = true;
hand = pointAngle(x, y);
handSpeed = 0;
handTime = performance.now();
setWin(-1);
wake();
},
drag(x, y) {
if (!held) return;
const a = pointAngle(x, y);
const now = performance.now();
const d = Math.atan2(Math.sin(a - hand), Math.cos(a - hand));
const gap = Math.max(1, now - handTime) / 1000;
handSpeed = handSpeed * 0.5 + (d / gap) * 0.5;
angle += d;
hand = a;
handTime = now;
},
drop() {
if (!held) return;
held = false;
const flick = performance.now() - handTime < 120 ? clamp(handSpeed, 22) : 0;
if (Math.abs(flick) > 3) {
this.spin(flick);
return;
}
speed = 0; // A slow let-go just settles off the peg.
},
};
return () => {
cancelAnimationFrame(raf);
engine.current = null;
void audio?.close();
};
}, [items, n, slice, sound, reduce]);
const local = (e: React.PointerEvent) => {
const box = svg.current!.getBoundingClientRect();
return { x: ((e.clientX - box.left) / box.width) * SIZE, y: ((e.clientY - box.top) / box.height) * SIZE };
};
const fills = items.map((_, i) => (i === n - 1 && n % colors.length === 1 ? colors[1 % colors.length] : colors[i % colors.length]));
const won = win >= 0 ? items[win] : "";
const hubSize = won ? Math.min(16, 118 / Math.max(5, won.length)) : 19;
return (
<div className={`relative ${className}`}>
<style>{`@keyframes ${id}-chase{0%,100%{fill:#ffe9a8}50%{fill:#6b4a14}}`}</style>
<svg
ref={svg}
viewBox={`0 0 ${SIZE} ${SIZE}`}
role="img"
aria-label={label}
className="block h-auto w-full cursor-grab touch-none select-none active:cursor-grabbing"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
const { x, y } = local(e);
engine.current?.grab(x, y);
}}
onPointerMove={(e) => {
const { x, y } = local(e);
engine.current?.drag(x, y);
}}
onPointerUp={() => engine.current?.drop()}
onPointerCancel={() => engine.current?.drop()}
>
<defs>
<radialGradient id={`${id}-sheen`} cx="0.36" cy="0.26" r="0.82">
<stop offset="0" stopColor="#fff" stopOpacity="0.34" />
<stop offset="0.5" stopColor="#fff" stopOpacity="0" />
<stop offset="1" stopColor="#000" stopOpacity="0.34" />
</radialGradient>
<linearGradient id={`${id}-brass`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#fbe7a6" />
<stop offset="0.5" stopColor="#c8972f" />
<stop offset="1" stopColor="#6f4a12" />
</linearGradient>
<linearGradient id={`${id}-wood`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#7c4522" />
<stop offset="1" stopColor="#32170a" />
</linearGradient>
</defs>
{/* Wooden rim with its bulbs, fixed to the wall. */}
<circle cx={CX + 3} cy={CY + 5} r={R + 25} fill="#000" fillOpacity="0.4" />
<circle cx={CX} cy={CY} r={R + 25} fill={`url(#${id}-wood)`} stroke="#1c0c04" strokeWidth="1" />
{Array.from({ length: BULBS }, (_, i) => {
const [x, y] = polar(R + 14, (i / BULBS) * TAU);
return (
<g key={i}>
<circle cx={x} cy={y} r="6.2" fill="#000" fillOpacity="0.4" />
<circle
cx={x}
cy={y}
r="4.6"
fill="#ffe9a8"
stroke="#c8972f"
strokeWidth="1"
style={spinning ? { animation: `${id}-chase 0.54s linear ${-(i % 3) * 0.18}s infinite` } : undefined}
/>
</g>
);
})}
{/* The turning face. Its transform stays a constant prop, so renders leave the live angle alone. */}
<g ref={wheel} transform={`rotate(${(((TAU / n) / 2) * 180) / Math.PI} ${CX} ${CY})`}>
{items.map((item, i) => {
const a = -Math.PI / 2 + i * slice;
const b = a + slice;
const [x1, y1] = polar(R, a);
const [x2, y2] = polar(R, b);
const mid = ((a + slice / 2) * 180) / Math.PI;
return (
<g key={i} style={{ opacity: win >= 0 && win !== i ? 0.5 : 1, transition: "opacity 0.3s" }}>
<path d={`M${CX} ${CY}L${x1.toFixed(2)} ${y1.toFixed(2)}A${R} ${R} 0 0 1 ${x2.toFixed(2)} ${y2.toFixed(2)}Z`} fill={fills[i]} stroke="#fff4d6" strokeOpacity="0.6" strokeWidth="1" />
<text
x={CX + R - 21}
y={CY}
transform={`rotate(${mid.toFixed(2)} ${CX} ${CY})`}
textAnchor="end"
dominantBaseline="central"
fontSize={Math.min(16, 136 / Math.max(6, item.length))}
fontWeight="700"
fill={dark(fills[i]) ? "#fff7e4" : "#3a2410"}
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{item}
</text>
</g>
);
})}
{items.map((_, i) => {
const [x, y] = polar(R - 3, -Math.PI / 2 + i * slice);
return (
<g key={i}>
<circle cx={x + 0.8} cy={y + 1.2} r="4.8" fill="#000" fillOpacity="0.4" />
<circle cx={x} cy={y} r="4.4" fill={`url(#${id}-brass)`} stroke="#4d3009" strokeWidth="0.6" />
</g>
);
})}
</g>
{/* Lacquer light does not turn with the wheel. */}
<circle cx={CX} cy={CY} r={R} fill={`url(#${id}-sheen)`} pointerEvents="none" />
<circle cx={CX} cy={CY} r={R} fill="none" stroke="#1c0c04" strokeWidth="2" />
{/* Hub: the real button sits on top of it. */}
<circle cx={CX + 1.5} cy={CY + 3} r={HUB + 2} fill="#000" fillOpacity="0.4" />
<circle cx={CX} cy={CY} r={HUB} fill={`url(#${id}-brass)`} stroke="#4d3009" />
<circle cx={CX} cy={CY} r={HUB - 6} fill="#2a1409" stroke="#c8972f" strokeWidth="1.2" />
<text
x={CX}
y={CY}
textAnchor="middle"
dominantBaseline="central"
fontSize={hubSize}
fontWeight="700"
fill="#ffe9a8"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{won || (spinning ? "" : "Spin")}
</text>
{/* Leather flipper, pinned at the top of the rim. */}
<g ref={flipper}>
<path d={`M${CX - 7} 8Q${CX} 2 ${CX + 7} 8L${CX + 3.4} 48Q${CX} 54 ${CX - 3.4} 48Z`} fill="#6e2413" stroke="#280a03" strokeWidth="1" />
<path d={`M${CX} 12V46`} stroke="#c8765a" strokeOpacity="0.6" strokeWidth="1" strokeDasharray="2 3" />
</g>
<circle cx={CX} cy="8" r="6.5" fill={`url(#${id}-brass)`} stroke="#4d3009" />
</svg>
<button
type="button"
aria-label="Spin the wheel"
aria-disabled={spinning}
onClick={() => {
if (!spinning) engine.current?.spin((10 + Math.random() * 6) * (Math.random() < 0.5 ? 1 : -1));
}}
className="absolute cursor-pointer bg-transparent"
style={{ left: `${((CX - HUB) / SIZE) * 100}%`, top: `${((CY - HUB) / SIZE) * 100}%`, width: `${((HUB * 2) / SIZE) * 100}%`, height: `${((HUB * 2) / SIZE) * 100}%`, borderRadius: "50%" }}
/>
<span role="status" className="sr-only">
{won ? `Landed on ${won}` : ""}
</span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/spin-wheel.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/spin-wheel-demo.tsx
import { SpinWheel } from "@/components/ui/spin-wheel"; export default function SpinWheelExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_45%,#20345a,#090f1d_78%)] p-1"> <SpinWheel className="w-[min(98cqh,92cqw,460px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | string[] | 8 snack prizes | Prizes round the wheel, 3 or more. |
| colors | string[] | 4 enamel colours | Used in turn, as six-digit hex. |
| sound | boolean | true | Tick on each peg. |
| onResult | (item: string, index: number) => void | - | Called with the winning item and its index. |
| label | string | "Prize wheel" | Read out by screen readers. |
| className | string | "w-96" | Set the width here. Everything scales with it. |