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

iPad Mini
Gift Card
Amazon Echo
Coffee Mug
T-shirt
Bluetooth Speaker
Water Bottle
Stickers
Notebook
Socks
Bag
Mystery Prize

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.tsx
  • src/components/Examples/Wheel/WheelExample.tsx
  • src/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 &amp; 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:

NameTypeDefaultDescription
prizesArraystring[]The labels shown on each wheel segment (required)
winningIndexnumberIndex (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?

Was this page helpful?