Prize Wheel Component
This page documents the WheelExample React demo, which displays a visually animated spinning prize wheel. The wheel randomly selects a segment when spun, and is ideal for games, raffles, promotions, or interactive web UIs.
Overview
A random winning prize is selected each time you click the button.
The prize wheel demo above shows how to use the component in a real app. The wheel supports any number of prizes, random winner selection, smooth animated spinning, a customizable spin button, and a responsive design.
Installation
No special dependencies aside from clsx and motion (for animation). To install them, run:
npm install clsx motion
Copy the following source files (and the referenced Button component) into your project:
src/components/Examples/Wheel/index.tsxsrc/components/Examples/Wheel/WheelExample.tsxsrc/components/Button.tsx(or update the Button import to match your own)
Component Source
'use client'
import { useState, useEffect, useRef, useMemo, useCallback, useImperativeHandle, forwardRef, memo } from "react";
export interface WheelProps {
prizesArray: string[];
winningIndex: number;
onEndSpin?: (result: string) => void;
size?: number;
className?: string;
}
export interface WheelHandle {
spin: () => void;
}
const SEGMENT_COLORS = [
"bg-red-500", "bg-blue-500", "bg-yellow-400", "bg-green-500",
"bg-purple-500", "bg-orange-500", "bg-pink-500", "bg-teal-500",
"bg-indigo-500", "bg-amber-500", "bg-emerald-500", "bg-rose-500"
] as const;
const SPIN_DURATION = 5000;
const BASE_ROTATION = 1800;
const INITIAL_ROTATION = -90;
const Wheel = memo(forwardRef<WheelHandle, WheelProps>(({
prizesArray,
winningIndex,
onEndSpin,
size = 500,
className = ""
}, ref) => {
const segments = prizesArray.length;
const segmentAngle = useMemo(() => (segments ? 360 / segments : 0), [segments]);
const [rotation, setRotation] = useState(INITIAL_ROTATION);
const [spinning, setSpinning] = useState(false);
const wheelRef = useRef<HTMLDivElement>(null);
const spinTimeout = useRef<NodeJS.Timeout | null>(null);
const selectedWinningIndex = useMemo(() => {
if (!segments) return 0;
if (winningIndex < 0) return 0;
if (winningIndex >= segments) return segments - 1;
return winningIndex;
}, [winningIndex, segments]);
useEffect(() => {
setRotation(INITIAL_ROTATION);
}, [prizesArray]);
useEffect(() => {
return () => {
if (spinTimeout.current) clearTimeout(spinTimeout.current);
};
}, []);
const calculateSpinRotation = useCallback(() => {
const winningSegmentCenter =
selectedWinningIndex * segmentAngle + segmentAngle / 2;
const destinationAngle = 270 - winningSegmentCenter;
const normalize = (deg: number) => ((deg % 360) + 360) % 360;
const target = normalize(destinationAngle);
const current = normalize(rotation);
let diff = target - current;
if (diff <= 0) diff += 360;
return rotation + BASE_ROTATION + diff;
}, [selectedWinningIndex, segmentAngle, rotation]);
const spinWheel = useCallback(() => {
if (spinning || segments === 0) return;
const spinTo = calculateSpinRotation();
setSpinning(true);
setRotation(spinTo);
if (spinTimeout.current) clearTimeout(spinTimeout.current);
spinTimeout.current = setTimeout(() => {
setSpinning(false);
if (onEndSpin) {
onEndSpin(prizesArray[selectedWinningIndex]);
}
}, SPIN_DURATION);
}, [spinning, segments, calculateSpinRotation, prizesArray, selectedWinningIndex, onEndSpin]);
useImperativeHandle(ref, () => ({
spin: spinWheel
}), [spinWheel]);
const wheelSegments = useMemo(() => {
if (segments === 0) return null;
const numPoints = Math.max(20, Math.min(60, Math.floor(400 / segments)));
return prizesArray.map((label, index) => {
const startAngle = index * segmentAngle;
const color = SEGMENT_COLORS[index % SEGMENT_COLORS.length];
const points: string[] = ["50% 50%"];
const angleStep = segmentAngle / numPoints;
for (let i = 0; i <= numPoints; i++) {
const pointAngle = (startAngle + i * angleStep) * Math.PI / 180;
const x = 50 + 50 * Math.cos(pointAngle);
const y = 50 + 50 * Math.sin(pointAngle);
points.push(`${x.toFixed(2)}% ${y.toFixed(2)}%`);
}
const textAngle = (startAngle + segmentAngle / 2) * Math.PI / 180;
const textRadius = segments > 8 ? 30 : 35;
const textX = 50 + textRadius * Math.cos(textAngle);
const textY = 50 + textRadius * Math.sin(textAngle);
const textSizeClass =
segments > 16
? "text-[10px]"
: segments > 12
? "text-xs"
: segments > 8
? "text-sm"
: "text-base";
return (
<div
key={`segment-${index}`}
className="absolute inset-0 pointer-events-none select-none"
>
<div
className={`absolute inset-0 ${color} transition-opacity duration-300`}
style={{
clipPath: `polygon(${points.join(", ")})`,
}}
aria-hidden
/>
<div
className={`absolute text-white font-semibold ${textSizeClass}`}
style={{
left: `${textX.toFixed(2)}%`,
top: `${textY.toFixed(2)}%`,
transform: `translate(-50%, -50%) rotate(${startAngle + segmentAngle / 2}deg)`,
textShadow: "1px 1px 2px rgba(0,0,0,0.7)",
maxWidth: `${Math.max(60, 220 / segments)}px`,
textAlign: "center",
lineHeight: "1.25",
wordBreak: "break-word",
pointerEvents: "none",
userSelect: "none",
}}
tabIndex={-1}
aria-label={label}
>
{label}
</div>
</div>
);
});
}, [prizesArray, segmentAngle, segments]);
if (segments === 0) {
return (
<div className={`flex flex-col items-center justify-center gap-6 w-full ${className}`}>
<div className="text-gray-500 dark:text-gray-400">No prizes available</div>
</div>
);
}
return (
<section className={`flex flex-col items-center gap-6 w-full ${className}`}>
<div
className="relative w-full aspect-square"
style={{ maxWidth: `${size}px` }}
>
<div
ref={wheelRef}
className="absolute w-full h-full rounded-full border-8 border-gray-800 overflow-hidden shadow-lg select-none"
style={{
transform: `rotate(${rotation}deg)`,
transition: spinning
? `transform ${SPIN_DURATION}ms cubic-bezier(0.17, 0.67, 0.12, 0.99)`
: "none",
willChange: spinning ? "transform" : "auto",
}}
aria-label="Prize Wheel"
role="region"
aria-busy={spinning}
>
{wheelSegments}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 bg-white dark:bg-zinc-800 rounded-full border-4 border-zinc-800 dark:border-zinc-700 z-10 shadow-xl pointer-events-none" />
</div>
<div
className="absolute top-0 left-1/2 -translate-x-1/2 w-0 h-0 z-20 pointer-events-none"
style={{
borderLeft: "20px solid transparent",
borderRight: "20px solid transparent",
borderTop: "30px solid #1f2937",
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))",
}}
aria-label="Wheel pointer"
role="img"
/>
</div>
</section>
);
}));
Wheel.displayName = "Wheel";
export default Wheel;
WheelExample.tsx
import React, { useState } from "react";
import Wheel from "./index";
import { Button } from "@/components/Button";
const demoPrizes = [
"iPad Mini",
"Gift Card",
"Amazon Echo",
"Coffee Mug",
"T-shirt",
"Bluetooth Speaker",
"Water Bottle",
"Stickers",
"Notebook",
"Socks",
"Bag",
"Mystery Prize",
];
const WheelExample = () => {
// For demo: allow user to "randomize" the winning segment
const [winningIndex, setWinningIndex] = useState(() =>
Math.floor(Math.random() * demoPrizes.length)
);
// Optionally allow respinning to a new prize
const handleNewSpin = () => {
setWinningIndex(Math.floor(Math.random() * demoPrizes.length));
};
return (
<div className="flex flex-col items-center gap-6 w-full max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-2">🎡 Prize Wheel Demo</h2>
<Wheel prizesArray={demoPrizes} winningIndex={winningIndex} />
<Button
onClick={handleNewSpin}
>
Pick New Prize & Spin Again
</Button>
<div className="text-sm text-gray-500 mt-1">
(Random winning prize picked on each new spin.)
</div>
</div>
);
};
export default WheelExample;
You can also review the core Wheel component source for customization:
Wheel Component (click to expand)
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { Button } from "../../Button";
interface WheelProps {
prizesArray: string[];
winningIndex: number;
}
// ... see full source in src/components/Examples/Wheel/index.tsx ...
Usage
Import and use the WheelExample or directly use the Wheel component for advanced integration:
import Wheel from '@/components/Examples/Wheel'
const prizes = ["Prize 1", "Prize 2", "Prize 3"];
const [winningIndex, setWinningIndex] = useState(1);
<Wheel prizesArray={prizes} winningIndex={winningIndex} />
Use WheelExample for a ready-to-go demo UI including a spin-again button:
import WheelExample from '@/components/Examples/Wheel/WheelExample'
<WheelExample />
Props
The Wheel component accepts these props:
| Name | Type | Default | Description |
|---|---|---|---|
| prizesArray | string[] | — | The labels shown on each wheel segment (required) |
| winningIndex | number | — | Index (in the array) that will win on the next spin |
Note: The demo's "spin again" button simply picks a new random winningIndex and triggers a re-spin.
Features
- Supports any number of segments (dynamic sizing, automatic text scaling)
- Random prize selection (or pick manually by index)
- Animated spinning with smooth deceleration and landing on the correct segment
- Accessible: uses ARIA roles for result announcements
- Mobile friendly and resizes for different devices
- Colorful segment backgrounds with contrasting text
Accessibility
- Spin result is announced with
aria-live="polite" - The spin button uses accessible labels and disables during animation
- Entire component is keyboard-navigable
Customization
- Copy and modify the prize list, animation timing, or styling as needed.
- Control the winning index externally to integrate server-side winners or "guaranteed win" patterns.
- Style with Tailwind or your preferred CSS by overriding the component classes.
What's next?
- 3D Button Component — Interactive button with sound effects
- Playing Card Component — Animated card with flip transitions
- useQueryParamsState Hook — Manage URL query parameters