Gradio custom HTML component: Spin Wheel
Author: ysharma
Category: display
Description: Spin the wheel to win a prize!

Head:

HTML template:

<div class="wheel-container">
    <div class="wheel-wrapper">
        <!-- Pointer -->
        <div class="wheel-pointer">▼</div>
        
        <!-- Wheel - rotation prop preserves position across re-renders -->
        <div class="wheel" id="prize-wheel" style="transform: rotate(${rotation || 0}deg);">
            <svg viewBox="0 0 400 400" class="wheel-svg">
                ${(() => {
                    const segments = JSON.parse(segments_json || '[]');
                    const cx = 200, cy = 200, r = 180;
                    let html = '';
                    
                    segments.forEach((seg, i) => {
                        const startRad = (seg.startAngle - 90) * Math.PI / 180;
                        const endRad = (seg.endAngle - 90) * Math.PI / 180;
                        const x1 = cx + r * Math.cos(startRad);
                        const y1 = cy + r * Math.sin(startRad);
                        const x2 = cx + r * Math.cos(endRad);
                        const y2 = cy + r * Math.sin(endRad);
                        const largeArc = seg.endAngle - seg.startAngle > 180 ? 1 : 0;
                        
                        const d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
                        html += `<path d="${d}" fill="${seg.color}" stroke="#fff" stroke-width="2" class="segment" data-index="${i}"/>`;
                        
                        // Text label
                        const midRad = (seg.midAngle - 90) * Math.PI / 180;
                        const textR = r * 0.65;
                        const tx = cx + textR * Math.cos(midRad);
                        const ty = cy + textR * Math.sin(midRad);
                        const rotation = seg.midAngle;
                        html += `<text x="${tx}" y="${ty}" fill="#fff" font-size="11" font-weight="bold" 
                                 text-anchor="middle" dominant-baseline="middle" 
                                 transform="rotate(${rotation}, ${tx}, ${ty})"
                                 style="text-shadow: 1px 1px 2px rgba(0,0,0,0.5); pointer-events: none;">${seg.label}</text>`;
                    });
                    
                    // Center circle
                    html += `<circle cx="200" cy="200" r="35" fill="#1a1a2e" stroke="#FFD700" stroke-width="4" class="center-btn"/>`;
                    html += `<text x="200" y="200" fill="#FFD700" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="middle" style="pointer-events: none;">SPIN</text>`;
                    
                    return html;
                })()}
            </svg>
        </div>
        
        <!-- Decorative lights -->
        <div class="wheel-lights">
            ${Array.from({length: 16}, (_, i) => `<div class="light" style="--i: ${i}"></div>`).join('')}
        </div>
    </div>
    
    <!-- Result Display -->
    <div class="result-display ${value ? 'show' : ''}" id="result-display">
        ${value ? `<div class="result-text">🎉 You won: <strong>${value}</strong></div>` : '<div class="result-text">Spin to win!</div>'}
    </div>
    
    <!-- Spin Button -->
    <button class="spin-button" id="spin-btn">
        🎰 SPIN TO WIN!
    </button>
    
    <!-- Confetti container -->
    <div class="confetti-container" id="confetti"></div>
</div>


CSS:

    .wheel-container {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 24px;
        background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
        border-radius: 24px;
        position: relative;
        overflow: hidden;
        min-height: 500px;
    }
    
    .wheel-wrapper {
        position: relative;
        width: 320px;
        height: 320px;
    }
    
    .wheel {
        width: 100%;
        height: 100%;
        transform: rotate(0deg);
    }
    
    .wheel-svg {
        width: 100%;
        height: 100%;
        filter: drop-shadow(0 10px 30px rgba(0,0,0,0.4));
    }
    
    .segment {
        cursor: pointer;
        transition: filter 0.2s;
    }
    
    .segment:hover {
        filter: brightness(1.1);
    }
    
    .center-btn {
        cursor: pointer;
        transition: all 0.2s;
    }
    
    .center-btn:hover {
        filter: brightness(1.2);
    }
    
    /* Pointer */
    .wheel-pointer {
        position: absolute;
        top: -10px;
        left: 50%;
        transform: translateX(-50%);
        font-size: 48px;
        color: #FFD700;
        z-index: 10;
        filter: drop-shadow(0 4px 6px rgba(0,0,0,0.5));
        animation: bounce 0.6s ease-in-out infinite;
    }
    
    @keyframes bounce {
        0%, 100% { transform: translateX(-50%) translateY(0); }
        50% { transform: translateX(-50%) translateY(8px); }
    }
    
    /* Decorative lights */
    .wheel-lights {
        position: absolute;
        top: 50%;
        left: 50%;
        width: 360px;
        height: 360px;
        transform: translate(-50%, -50%);
        pointer-events: none;
    }
    
    .light {
        position: absolute;
        width: 14px;
        height: 14px;
        background: #FFD700;
        border-radius: 50%;
        top: 50%;
        left: 50%;
        transform-origin: center;
        transform: rotate(calc(var(--i) * 22.5deg)) translateY(-180px);
        box-shadow: 0 0 10px #FFD700, 0 0 20px #FFD700;
        animation: blink 0.5s ease-in-out infinite alternate;
        animation-delay: calc(var(--i) * 0.08s);
    }
    
    @keyframes blink {
        0% { opacity: 0.4; transform: rotate(calc(var(--i) * 22.5deg)) translateY(-180px) scale(0.8); }
        100% { opacity: 1; transform: rotate(calc(var(--i) * 22.5deg)) translateY(-180px) scale(1); box-shadow: 0 0 15px #FFD700, 0 0 30px #FFD700; }
    }
    
    /* Spin Button */
    .spin-button {
        margin-top: 24px;
        padding: 18px 56px;
        font-size: 20px;
        font-weight: 700;
        color: white;
        background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
        border: none;
        border-radius: 50px;
        cursor: pointer;
        box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
        transition: all 0.3s ease;
        text-transform: uppercase;
        letter-spacing: 2px;
    }
    
    .spin-button:hover:not(:disabled) {
        transform: translateY(-3px);
        box-shadow: 0 12px 35px rgba(255, 107, 107, 0.5);
    }
    
    .spin-button:active:not(:disabled) {
        transform: translateY(0);
    }
    
    .spin-button:disabled {
        background: linear-gradient(135deg, #666 0%, #888 100%);
        cursor: not-allowed;
        box-shadow: none;
        transform: none;
    }
    
    /* Result Display */
    .result-display {
        margin-top: 20px;
        padding: 16px 32px;
        background: rgba(255,255,255,0.1);
        border-radius: 12px;
        backdrop-filter: blur(10px);
        border: 1px solid rgba(255,255,255,0.1);
        opacity: 0.7;
        transform: scale(0.95);
        transition: all 0.5s ease;
    }
    
    .result-display.show {
        opacity: 1;
        transform: scale(1);
        background: rgba(255,215,0,0.15);
        border-color: rgba(255,215,0,0.3);
    }
    
    .result-text {
        font-size: 18px;
        color: #fff;
        text-align: center;
    }
    
    .result-text strong {
        color: #FFD700;
        font-size: 22px;
        display: block;
        margin-top: 4px;
    }
    
    /* Confetti */
    .confetti-container {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;
        overflow: hidden;
    }
    
    .confetti {
        position: absolute;
        width: 10px;
        height: 10px;
        animation: fall 3s ease-out forwards;
    }
    
    @keyframes fall {
        0% {
            transform: translateY(-100px) rotate(0deg);
            opacity: 1;
        }
        100% {
            transform: translateY(600px) rotate(720deg);
            opacity: 0;
        }
    }
    
    /* Spinning state indicator */
    .wheel-container.spinning .wheel-pointer {
        animation: none;
        color: #fff;
    }
    
    .wheel-container.spinning .light {
        animation: rapid-blink 0.1s ease-in-out infinite alternate;
    }
    
    @keyframes rapid-blink {
        0% { opacity: 0.3; }
        100% { opacity: 1; }
    }


JavaScript (on load):

    const container = element.querySelector('.wheel-container');
    const wheel = element.querySelector('#prize-wheel');
    const spinBtn = element.querySelector('#spin-btn');
    const confettiContainer = element.querySelector('#confetti');
    const resultDisplay = element.querySelector('#result-display');
    
    let isSpinning = false;
    
    // Initialize totalRotation from the rotation prop (persists across re-renders)
    let totalRotation = parseFloat(props.rotation) || 0;
    
    function getSegments() {
        try {
            return JSON.parse(props.segments_json || '[]');
        } catch (e) {
            return [];
        }
    }
    
    function createConfetti() {
        const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFEAA7', '#DDA0DD', '#FFD700', '#FF8E53', '#96CEB4'];
        for (let i = 0; i < 150; i++) {
            setTimeout(() => {
                const confetti = document.createElement('div');
                confetti.className = 'confetti';
                confetti.style.left = Math.random() * 100 + '%';
                confetti.style.background = colors[Math.floor(Math.random() * colors.length)];
                confetti.style.animationDelay = Math.random() * 0.3 + 's';
                confetti.style.animationDuration = (2 + Math.random() * 2) + 's';
                const size = 6 + Math.random() * 10;
                confetti.style.width = size + 'px';
                confetti.style.height = size + 'px';
                confetti.style.borderRadius = Math.random() > 0.5 ? '50%' : Math.random() > 0.5 ? '0' : '2px';
                confettiContainer.appendChild(confetti);
                
                setTimeout(() => confetti.remove(), 4000);
            }, i * 15);
        }
    }
    
    function spinWheel() {
        if (isSpinning) return;
        
        const segments = getSegments();
        if (segments.length === 0) return;
        
        isSpinning = true;
        container.classList.add('spinning');
        spinBtn.disabled = true;
        spinBtn.textContent = '🎰 SPINNING...';
        
        // Pick a random winning segment
        const winningIndex = Math.floor(Math.random() * segments.length);
        const winningSegment = segments[winningIndex];
        
        // === ROTATION MATH ===
        // The pointer is fixed at TOP (0°). Segments are drawn with 0° at top.
        // For pointer to hit segment, we need rotation where segment's midAngle aligns with top.
        
        // Add random offset within segment bounds
        const segmentSize = winningSegment.endAngle - winningSegment.startAngle;
        const randomOffset = (Math.random() - 0.5) * segmentSize * 0.7;
        
        // Target position (mod 360) where wheel should stop
        const targetMod = ((360 - winningSegment.midAngle + randomOffset) % 360 + 360) % 360;
        
        // Current wheel position (mod 360)
        const currentMod = ((totalRotation % 360) + 360) % 360;
        
        // Calculate additional rotation needed (always spin forward)
        let additionalRotation = targetMod - currentMod;
        if (additionalRotation <= 0) additionalRotation += 360;
        
        // Add extra full spins for dramatic effect (5-7 spins)
        const extraSpins = 5 + Math.floor(Math.random() * 3);
        const finalRotation = totalRotation + extraSpins * 360 + additionalRotation;
        
        // Apply rotation with CSS transition
        wheel.style.transition = 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)';
        wheel.style.transform = `rotate(${finalRotation}deg)`;
        
        // Store FULL rotation for next spin
        totalRotation = finalRotation;
        
        // After spin completes, determine winner from ACTUAL landing position
        setTimeout(() => {
            isSpinning = false;
            container.classList.remove('spinning');
            spinBtn.disabled = false;
            spinBtn.textContent = '🎰 SPIN AGAIN!';
            
            // Verify which segment the pointer actually landed on
            const landedRotation = ((totalRotation % 360) + 360) % 360;
            const pointerAngle = ((360 - landedRotation) % 360 + 360) % 360;
            
            // Find which segment contains this angle
            let actualWinner = winningSegment;
            for (const seg of segments) {
                if (pointerAngle >= seg.startAngle && pointerAngle < seg.endAngle) {
                    actualWinner = seg;
                    break;
                }
            }
            // Handle wrap-around edge case
            if (pointerAngle >= segments[segments.length - 1].endAngle || pointerAngle < segments[0].startAngle) {
                if (segments[0].startAngle === 0) {
                    actualWinner = segments[0];
                }
            }
            
            // Persist rotation as prop so it survives re-renders
            props.rotation = totalRotation;
            
            // Update value - this automatically triggers 'change' event
            // Do NOT call trigger('change') separately or you'll get duplicate events!
            props.value = actualWinner.label;
            
            createConfetti();
            
            console.log('Spin complete:', {
                finalRotation: totalRotation,
                pointerAngle,
                winner: actualWinner.label
            });
        }, 4100);
    }
    
    // Button click
    spinBtn.addEventListener('click', spinWheel);
    
    // Click on wheel center
    element.addEventListener('click', (e) => {
        if (e.target && e.target.classList.contains('center-btn')) {
            spinWheel();
        }
    });
    
    // Keyboard support
    element.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            spinWheel();
        }
    });


Python code:
class SpinWheel(gr.HTML):
    """
    An interactive prize wheel component with:
    - Smooth CSS spinning animation
    - Customizable segments with colors
    - Win detection with callbacks
    - Confetti celebration effect
    """
    def __init__(
        self,
        value=None,  # Currently selected/won prize (string)
        segments=None,  # List of {"label": str, "color": str, "weight": int}
        segments_json=None,  # JSON string of computed segments (for updates)
        rotation=0,  # Current wheel rotation in degrees (persists position)
        **kwargs
    ):
        # Use provided segments or default
        if segments is None:
            segments = DEFAULT_SEGMENTS

        # Compute segment data if not provided as JSON
        if segments_json is None:
            segment_data = compute_segment_data(segments)
            segments_json = json.dumps(segment_data)

        super().__init__(
            value=value,
            segments_json=segments_json,
            rotation=rotation,
            html_template=HTML_TEMPLATE,
            css_template=CSS_TEMPLATE,
            js_on_load=JS_ON_LOAD,
            **kwargs
        )

    def api_info(self):
        return {"type": "string", "description": "The label of the winning segment"}