Spin Wheel

Spin the wheel to win a prize!

▼

            <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>

            ${Array.from({length: 16}, (_, i) => `<div class="light" style="--i: ${i}"></div>`).join('')}

        ${value ? `<div class="result-text">🎉 You won: <strong>${value}</strong></div>` : '
Spin to win!
'}

    <button class="spin-button" id="spin-btn">
        🎰 SPIN TO WIN!
    </button>

    .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; }
    }

    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();
        }
    });

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"}


Star Rating

Click stars to set a rating from 1 to 5

class StarRating(gr.HTML):
    def __init__(self, label, value=0, **kwargs):
        html_template = """
        <h2>${label} rating:</h2>
        ${Array.from({length: 5}, (_, i) => `<img class='${i < value ? '' : 'faded'}' src='https://upload.wikimedia.org/wikipedia/commons/d/df/Award-star-gold-3d.svg'>`).join('')}
        """
        css_template = """
            img { height: 50px; display: inline-block; cursor: pointer; }
            .faded { filter: grayscale(100%); opacity: 0.3; }
        """
        js_on_load = """
            const imgs = element.querySelectorAll('img');
            imgs.forEach((img, index) => {
                img.addEventListener('click', () => {
                    props.value = index + 1;
                });
            });
        """
        super().__init__(
            value=value, label=label,
            html_template=html_template,
            css_template=css_template,
            js_on_load=js_on_load, **kwargs
        )

    def api_info(self):
        return {"type": "integer", "minimum": 0, "maximum": 5}


Button Set

Multiple buttons that trigger events with click data

button {
    padding: 10px 20px;
    background-color: #f97316;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    font-weight: 600;
    margin-right: 8px;
    transition: background 0.15s;
}
button:hover { background-color: #ea580c; }

button_set = gr.HTML(
    html_template="""
    <button id='A'>A</button>
    <button id='B'>B</button>
    <button id='C'>C</button>
    """,
    css_template="""
    button {
        padding: 10px 20px;
        background-color: #f97316;
        color: white; border: none;
        border-radius: 8px; cursor: pointer;
    }
    button:hover { background-color: #ea580c; }
    """,
    js_on_load="""
    const buttons = element.querySelectorAll('button');
    buttons.forEach(button => {
        button.addEventListener('click', () => {
            trigger('click', {clicked: button.innerText});
        });
    });
    """
)

def on_button_click(evt: gr.EventData):
    return evt.clicked

button_set.click(on_button_click, outputs=clicked_box)


Progress Bar

Interactive progress bar - click the track to set progress

.progress-container { padding: 8px 0; }
.progress-label { font-weight: 600; margin-bottom: 8px; font-size: 15px; }
.progress-track { width: 100%; height: 24px; background: #e5e7eb; border-radius: 12px; overflow: hidden; cursor: pointer; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #f97316, #fb923c); border-radius: 12px; transition: width 0.4s ease; }
.progress-text { margin-top: 6px; font-size: 14px; color: #6b7280; text-align: right; font-variant-numeric: tabular-nums; }

const track = element.querySelector('.progress-track');
track.addEventListener('click', (e) => {
    const rect = track.getBoundingClientRect();
    const pct = Math.round(((e.clientX - rect.left) / rect.width) * 100);
    props.value = Math.max(0, Math.min(100, pct));
});

progress = gr.HTML(
    value=65,
    label="Upload Progress",
    html_template="""

${label}

${value}%

    """,
    css_template="""
    .progress-track {
        width: 100%; height: 24px;
        background: #e5e7eb; border-radius: 12px;
        overflow: hidden; cursor: pointer;
    }
    .progress-fill {
        height: 100%;
        background: linear-gradient(90deg, #f97316, #fb923c);
        border-radius: 12px; transition: width 0.4s ease;
    }
    """,
    js_on_load="""
    const track = element.querySelector('.progress-track');
    track.addEventListener('click', (e) => {
        const rect = track.getBoundingClientRect();
        const pct = Math.round(
            ((e.clientX - rect.left) / rect.width) * 100
        );
        props.value = Math.max(0, Math.min(100, pct));
    });
    """
)


Likert Scale

Agreement scale from Strongly Disagree to Strongly Agree

.likert-container { padding: 8px 0; }
.likert-question { font-weight: 600; margin-bottom: 16px; font-size: 15px; }
.likert-scale { display: flex; justify-content: space-between; gap: 4px; }
.likert-option { display: flex; flex-direction: column; align-items: center; cursor: pointer; flex: 1; gap: 8px; }
.likert-option input { display: none; }
.likert-dot { width: 24px; height: 24px; border-radius: 50%; border: 2px solid #d1d5db; transition: all 0.2s; }
.likert-option:hover .likert-dot { border-color: #f97316; }
.likert-option.selected .likert-dot { background: #f97316; border-color: #f97316; }
.likert-label { font-size: 11px; text-align: center; color: #6b7280; line-height: 1.3; }

Question: This component is easy to use

class LikertScale(gr.HTML):
    def __init__(self, question="Rate this item", value=0, **kwargs):
        html_template = """

${question}

                ${['Strongly Disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly Agree'].map((label, i) => `

                        <input type="radio" name="likert" value="${i + 1}" ${value === i + 1 ? 'checked' : ''} />

                        ${label}

                `).join('')}

        """
        css_template = """
        .likert-scale { display: flex; justify-content: space-between; }
        .likert-option { display: flex; flex-direction: column; align-items: center; cursor: pointer; }
        .likert-dot { width: 24px; height: 24px; border-radius: 50%; border: 2px solid #d1d5db; }
        .likert-option.selected .likert-dot { background: #f97316; border-color: #f97316; }
        """
        js_on_load = """
        const radios = element.querySelectorAll('input[type="radio"]');
        radios.forEach(radio => {
            radio.addEventListener('change', () => {
                props.value = parseInt(radio.value);
            });
        });
        """
        super().__init__(
            value=value, question=question,
            html_template=html_template,
            css_template=css_template,
            js_on_load=js_on_load, **kwargs
        )

    def api_info(self):
        return {"type": "integer", "minimum": 0, "maximum": 5}


Tag Input

Add and remove tags with keyboard input

        ${(value || []).map((tag, i) => `

                ${tag}
                <button class="tag-remove" data-index="${i}">×</button>

        `).join('')}
        <input type="text" class="tag-text-input" placeholder="${(value || []).length === 0 ? 'Type a tag and press Enter...' : 'Add more...'}" />

.tag-input-container { padding: 4px 0; }
.tag-list { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 8px; border: 1px solid #e5e7eb; border-radius: 8px; min-height: 42px; }
.tag-pill { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; background: #fff7ed; color: #ea580c; border-radius: 16px; font-size: 13px; font-weight: 500; border: 1px solid #fed7aa; }
.tag-remove { background: none; border: none; color: #ea580c; cursor: pointer; font-size: 16px; padding: 0 2px; line-height: 1; opacity: 0.6; }
.tag-remove:hover { opacity: 1; }
.tag-text-input { border: none; outline: none; flex: 1; min-width: 120px; font-size: 14px; padding: 4px; background: transparent; }

const input = element.querySelector('.tag-text-input');
input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && input.value.trim()) {
        e.preventDefault();
        const tags = [...(props.value || []), input.value.trim()];
        props.value = tags;
        input.value = '';
    }
    if (e.key === 'Backspace' && !input.value && props.value && props.value.length > 0) {
        props.value = props.value.slice(0, -1);
    }
});
element.addEventListener('click', (e) => {
    const btn = e.target.closest('.tag-remove');
    if (!btn) return;
    const idx = parseInt(btn.dataset.index);
    const tags = [...(props.value || [])];
    tags.splice(idx, 1);
    props.value = tags;
});

class TagInput(gr.HTML):
    def __init__(self, value=None, **kwargs):
        html_template = """

                ${(value || []).map((tag, i) => `

                        ${tag}
                        <button class="tag-remove" data-index="${i}">×</button>

                `).join('')}
                <input type="text" class="tag-text-input"
                       placeholder="Type a tag and press Enter..." />

        """
        css_template = """
        .tag-list {
            display: flex; flex-wrap: wrap; gap: 6px;
            padding: 8px; border: 1px solid #e5e7eb;
            border-radius: 8px;
        }
        .tag-pill {
            padding: 4px 10px; background: #fff7ed;
            color: #ea580c; border-radius: 16px;
            font-size: 13px; border: 1px solid #fed7aa;
        }
        """
        js_on_load = """
        const input = element.querySelector('.tag-text-input');
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' && input.value.trim()) {
                e.preventDefault();
                props.value = [...(props.value || []), input.value.trim()];
                input.value = '';
            }
        });
        element.addEventListener('click', (e) => {
            const btn = e.target.closest('.tag-remove');
            if (!btn) return;
            const idx = parseInt(btn.dataset.index);
            const tags = [...(props.value || [])];
            tags.splice(idx, 1);
            props.value = tags;
        });
        """
        super().__init__(
            value=value or [],
            html_template=html_template,
            css_template=css_template,
            js_on_load=js_on_load, **kwargs
        )

    def api_info(self):
        return {"type": "array", "items": {"type": "string"}}


Colored Checkbox Group

Multi-select checkbox group with custom colors per option

    ${label ? `<label class="container-label">${label}</label>` : ''}

        ${choices.map((choice, i) => `

                <input type="checkbox" value="${choice}" ${(value || []).includes(choice) ? 'checked' : ''}>
                ${choice}

        `).join('')}

.colored-checkbox-container { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; }
.container-label { display: block; margin-bottom: 12px; font-weight: 600; }
.colored-checkbox-group { display: flex; flex-direction: column; gap: 6px; }
.checkbox-label { display: flex; align-items: center; cursor: pointer; padding: 4px 0; }
.checkbox-label input { margin-right: 8px; }
${choices.map((choice, i) => `.checkbox-label[data-color-index="${i}"] { color: ${colors[i]}; }`).join(' ')}

class ColoredCheckboxGroup(gr.HTML):
    def __init__(self, choices, *, value=None, colors, label=None, **kwargs):
        html_template = """

            ${label ? `<label>${label}</label>` : ''}

                ${choices.map((choice, i) => `

                        <input type="checkbox" value="${choice}"
                               ${(value || []).includes(choice) ? 'checked' : ''}>
                        ${choice}

                `).join('')}

        """
        css_template = """
        .colored-checkbox-group { display: flex; flex-direction: column; gap: 6px; }
        .checkbox-label { display: flex; align-items: center; cursor: pointer; }
        ${choices.map((choice, i) =>
            `.checkbox-label[data-color-index="${i}"] { color: ${colors[i]}; }`
        ).join(' ')}
        """
        js_on_load = """
        const checkboxes = element.querySelectorAll('input[type="checkbox"]');
        checkboxes.forEach(checkbox => {
            checkbox.addEventListener('change', () => {
                props.value = Array.from(checkboxes)
                    .filter(cb => cb.checked)
                    .map(cb => cb.value);
            });
        });
        """
        super().__init__(
            value=value or [], choices=choices,
            colors=colors, label=label,
            html_template=html_template,
            css_template=css_template,
            js_on_load=js_on_load, **kwargs
        )


Todo List

Interactive checklist with strikethrough on completed items

h2 { font-size: 18px; font-weight: 700; margin-bottom: 8px; }
ul { padding: 0; margin: 0; }
li { font-size: 14px; transition: opacity 0.2s; }
li input { margin-right: 8px; cursor: pointer; }

const checkboxes = element.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
    checkbox.addEventListener('change', () => {
        const index = parseInt(checkbox.getAttribute('data-index'));
        let completed = props.completed || [];
        if (checkbox.checked) {
            if (!completed.includes(index)) {
                completed.push(index);
            }
        } else {
            completed = completed.filter(i => i !== index);
        }
        props.completed = [...completed];
    });
});

Buy groceries
Walk the dog
Read a book
Write code

class TodoList(gr.HTML):
    def __init__(self, value=None, completed=None, **kwargs):
        html_template = """

Todo List

            ${value.map((item, index) => `

                    <input type="checkbox" ${completed.includes(index) ? 'checked' : ''} data-index="${index}" />
                    ${item}

            `).join('')}

        """
        js_on_load = """
        const checkboxes = element.querySelectorAll('input[type="checkbox"]');
        checkboxes.forEach(checkbox => {
            checkbox.addEventListener('change', () => {
                const index = parseInt(checkbox.getAttribute('data-index'));
                let completed = props.completed || [];
                if (checkbox.checked) {
                    if (!completed.includes(index)) completed.push(index);
                } else {
                    completed = completed.filter(i => i !== index);
                }
                props.completed = [...completed];
            });
        });
        """
        super().__init__(
            value=value or [], completed=completed or [],
            html_template=html_template,
            js_on_load=js_on_load, **kwargs
        )


Audio Gallery

Grid of audio players with waveform visualization and selection

    ${label ? `<label class="container-label">${label}</label>` : ''}

        ${audio_urls.map((url, i) => `

${labels && labels[i] ? labels[i] : 'Audio ' + (i + 1)}

                <canvas class="waveform-canvas" data-url="${url}" width="300" height="80"></canvas>
                <audio src="${url}" preload="metadata" ${value === url ? 'data-selected="true"' : ''}></audio>

                    <button class="play-btn">▶</button>

0:00

        `).join('')}

.audio-gallery-container { padding: 8px; }
.container-label { display: block; margin-bottom: 12px; font-weight: 600; }
.audio-gallery-grid { display: grid; gap: 12px; }
.audio-item { border: 2px solid #e5e7eb; border-radius: 10px; padding: 12px; cursor: pointer; transition: all 0.2s; }
.audio-item:hover { border-color: #f97316; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.audio-item[data-selected="true"] { border-color: #f97316; background-color: #fff7ed; }
.audio-label { margin-bottom: 8px; text-align: center; font-size: 13px; font-weight: 500; }
.waveform-canvas { width: 100%; height: 80px; background: #f9fafb; border-radius: 6px; margin-bottom: 8px; }
.audio-controls { display: flex; align-items: center; gap: 8px; }
.play-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: #f97316; color: white; cursor: pointer; font-size: 14px; }
.play-btn:hover { opacity: 0.8; }
.time-display { font-size: 12px; color: #6b7280; }

const audioItems = element.querySelectorAll('.audio-item');
audioItems.forEach((item, index) => {
    const canvas = item.querySelector('.waveform-canvas');
    const audio = item.querySelector('audio');
    const playBtn = item.querySelector('.play-btn');
    const timeDisplay = item.querySelector('.time-display');
    const ctx = canvas.getContext('2d');
    drawWaveform(canvas, ctx);
    item.addEventListener('click', (e) => {
        if (e.target === playBtn) return;
        audioItems.forEach(i => i.removeAttribute('data-selected'));
        item.setAttribute('data-selected', 'true');
        props.value = audio.src;
    });
    playBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        if (audio.paused) {
            element.querySelectorAll('audio').forEach(a => a.pause());
            element.querySelectorAll('.play-btn').forEach(b => b.textContent = '▶');
            audio.play();
            playBtn.textContent = '⏸';
        } else {
            audio.pause();
            playBtn.textContent = '▶';
        }
    });
    audio.addEventListener('timeupdate', () => {
        const currentTime = Math.floor(audio.currentTime);
        const minutes = Math.floor(currentTime / 60);
        const seconds = currentTime % 60;
        timeDisplay.textContent = minutes + ':' + seconds.toString().padStart(2, '0');
        const progress = audio.currentTime / audio.duration;
        drawWaveform(canvas, ctx, progress);
    });
    audio.addEventListener('ended', () => {
        playBtn.textContent = '▶';
        drawWaveform(canvas, ctx, 0);
    });
});
function drawWaveform(canvas, ctx, progress) {
    progress = progress || 0;
    const width = canvas.width;
    const height = canvas.height;
    const bars = 50;
    const barWidth = width / bars;
    ctx.clearRect(0, 0, width, height);
    for (let i = 0; i < bars; i++) {
        const barHeight = (Math.sin(i * 0.5) * 0.3 + 0.5) * height * 0.8;
        const x = i * barWidth;
        const y = (height - barHeight) / 2;
        ctx.fillStyle = i / bars < progress ? '#f97316' : '#d1d5db';
        ctx.fillRect(x, y, barWidth - 2, barHeight);
    }
}

class AudioGallery(gr.HTML):
    def __init__(self, audio_urls, *, value=None, labels=None,
                 columns=3, label=None, **kwargs):
        html_template = """

            ${label ? `<label>${label}</label>` : ''}

                ${audio_urls.map((url, i) => `

                            ${labels && labels[i] ? labels[i] : 'Audio ' + (i+1)}

                        <canvas class="waveform-canvas" width="300" height="80"></canvas>
                        <audio src="${url}" preload="metadata"></audio>

                            <button class="play-btn">▶</button>

0:00

                `).join('')}

        """
        super().__init__(
            value=value, audio_urls=audio_urls,
            labels=labels, columns=columns, label=label,
            html_template=html_template,
            css_template=CSS_TEMPLATE,
            js_on_load=JS_ON_LOAD, **kwargs
        )


Kanban Board

Drag-and-drop Kanban board with inline editing, priority labels, and search

${board_title}

                🔍
                <input type="text" class="search-input" placeholder="Search cards..." />

                ${(() => {
                    const cols = (value && value.columns) || [];
                    const total = cols.reduce((sum, col) => sum + col.cards.length, 0);
                    const done = cols.find(c => c.id === 'done');
                    const doneCount = done ? done.cards.length : 0;
                    const pct = total > 0 ? Math.round((doneCount / total) * 100) : 0;
                    return '📊 ' + total + ' tasks' +
                           '✅ ' + doneCount + ' done (' + pct + '%)';
                })()}

        ${(() => {
            const cols = (value && value.columns) || [];
            const total = cols.reduce((sum, col) => sum + col.cards.length, 0);
            const done = cols.find(c => c.id === 'done');
            const doneCount = done ? done.cards.length : 0;
            const pct = total > 0 ? Math.round((doneCount / total) * 100) : 0;
            return '

';
        })()}

        ${((value && value.columns) || []).map((col, colIdx) => `

                        <button class="collapse-btn" data-col-idx="${colIdx}">${col.collapsed ? '▶' : '▼'}</button>
                        ${col.title}

                    ${col.cards.length}

                    ${col.cards.map((card, cardIdx) => `

${card.text}

                                        ${(card.tags || []).map(t => '' + t + '').join('')}

                                        <button class="priority-cycle" data-col-idx="${colIdx}" data-card-idx="${cardIdx}" title="Cycle priority">
                                            ${card.priority === 'high' ? '🔴' : card.priority === 'medium' ? '🟡' : '🟢'}
                                        </button>
                                        <button class="delete-card" data-col-idx="${colIdx}" data-card-idx="${cardIdx}" title="Delete card">✕</button>

                    `).join('')}

                    <input type="text" class="add-card-input" data-col-idx="${colIdx}" placeholder="+ Add a card…  ⏎" />

        `).join('')}

.kanban-wrapper {
    background: linear-gradient(135deg, #0f172a 0%, #1a1a2e 100%);
    border-radius: 16px;
    padding: 24px;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    color: #e2e8f0;
    overflow-x: auto;
}
.kanban-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-wrap: wrap; gap: 12px; }
.kanban-header h2 { margin: 0; font-size: 22px; color: #f8fafc; letter-spacing: -0.3px; }
.header-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.search-box { display: flex; align-items: center; background: rgba(255,255,255,0.06); border: 1px solid #334155; border-radius: 10px; padding: 4px 12px; transition: all 0.2s; }
.search-box:focus-within { border-color: #6366f1; background: rgba(99, 102, 241, 0.06); }
.search-icon { font-size: 13px; margin-right: 6px; }
.search-input { background: none; border: none; color: #e2e8f0; font-size: 13px; outline: none; width: 140px; }
.search-input::placeholder { color: #475569; }
.header-stats { display: flex; gap: 8px; }
.stat-pill { background: rgba(255,255,255,0.08); padding: 5px 14px; border-radius: 12px; font-size: 13px; color: #94a3b8; font-weight: 500; }
.done-pill { color: #10b981; }
.kb-progress-track { height: 4px; background: rgba(255,255,255,0.08); border-radius: 4px; margin-bottom: 20px; overflow: hidden; }
.kb-progress-bar { height: 100%; background: linear-gradient(90deg, #6366f1, #10b981); border-radius: 4px; transition: width 0.5s ease; }
.kanban-board { display: flex; gap: 16px; min-height: 400px; padding-bottom: 8px; }
.kanban-column { background: #1e293b; border-radius: 12px; min-width: 270px; max-width: 310px; flex: 1; display: flex; flex-direction: column; transition: min-width 0.3s, max-width 0.3s; }
.kanban-column.collapsed { min-width: 60px; max-width: 60px; }
.column-header { padding: 14px 14px 10px; display: flex; justify-content: space-between; align-items: center; border-radius: 12px 12px 0 0; user-select: none; }
.col-header-left { display: flex; align-items: center; gap: 8px; }
.collapse-btn { background: none; border: none; color: #64748b; cursor: pointer; font-size: 10px; padding: 2px 4px; border-radius: 4px; transition: color 0.2s; }
.collapse-btn:hover { color: #e2e8f0; }
.col-title { font-weight: 600; font-size: 14px; white-space: nowrap; }
.col-count { min-width: 26px; height: 26px; border-radius: 13px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; }
.card-list { flex: 1; padding: 6px 10px; min-height: 60px; transition: background 0.2s; }
.card-list.hidden, .add-card-area.hidden { display: none; }
.card-list.drag-over { background: rgba(99, 102, 241, 0.08); border-radius: 8px; }
.kanban-card { background: #0f172a; border: 1px solid #334155; border-radius: 10px; padding: 12px 12px 12px 16px; margin-bottom: 8px; cursor: grab; transition: all 0.15s ease; position: relative; overflow: hidden; animation: cardIn 0.2s ease; }
.kanban-card:hover { border-color: #6366f1; transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.3); }
.kanban-card.dragging { opacity: 0.4; transform: rotate(2deg) scale(0.97); }
.kanban-card.search-hidden { display: none; }
.kanban-card.search-highlight { border-color: #f59e0b; box-shadow: 0 0 0 1px #f59e0b44; }
.card-priority { width: 4px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 10px 0 0 10px; }
.priority-high { background: #ef4444; }
.priority-medium { background: #f59e0b; }
.priority-low { background: #10b981; }
.card-content { padding-left: 4px; }
.card-text { font-size: 13px; line-height: 1.5; color: #e2e8f0; cursor: text; border-radius: 4px; padding: 2px 4px; margin: -2px -4px; transition: background 0.15s; }
.card-text:hover { background: rgba(255,255,255,0.04); }
.card-text.editing { background: rgba(99, 102, 241, 0.1); outline: 1px solid #6366f1; min-height: 1.5em; }
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
.kb-tag { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; padding: 2px 9px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.card-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
.kanban-card:hover .card-actions { opacity: 1; }
.priority-cycle, .delete-card { background: none; border: none; cursor: pointer; font-size: 13px; padding: 3px 6px; border-radius: 6px; transition: all 0.15s; color: #475569; }
.delete-card:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
.priority-cycle:hover { background: rgba(255,255,255,0.08); }
.add-card-area { padding: 6px 10px 14px; }
.add-card-input { width: 100%; background: rgba(255,255,255,0.04); border: 1px dashed #334155; border-radius: 10px; padding: 10px 14px; color: #94a3b8; font-size: 13px; outline: none; transition: all 0.2s; box-sizing: border-box; }
.add-card-input:focus { border-color: #6366f1; border-style: solid; background: rgba(99, 102, 241, 0.05); color: #e2e8f0; }
.add-card-input::placeholder { color: #475569; }
@keyframes cardIn { from { opacity: 0; transform: translateY(-8px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } }

let dragSrcColIdx = null;
let dragSrcCardIdx = null;
element.addEventListener('dragstart', (e) => {
    const card = e.target.closest('.kanban-card');
    if (!card) return;
    dragSrcColIdx = parseInt(card.dataset.colIdx);
    dragSrcCardIdx = parseInt(card.dataset.cardIdx);
    card.classList.add('dragging');
    e.dataTransfer.effectAllowed = 'move';
});
element.addEventListener('dragend', (e) => {
    const card = e.target.closest('.kanban-card');
    if (card) card.classList.remove('dragging');
    element.querySelectorAll('.card-list').forEach(cl => cl.classList.remove('drag-over'));
});
element.addEventListener('dragover', (e) => {
    e.preventDefault();
    const cardList = e.target.closest('.card-list');
    if (cardList) cardList.classList.add('drag-over');
});
element.addEventListener('dragleave', (e) => {
    const cardList = e.target.closest('.card-list');
    if (cardList && !cardList.contains(e.relatedTarget)) {
        cardList.classList.remove('drag-over');
    }
});
element.addEventListener('drop', (e) => {
    e.preventDefault();
    const cardList = e.target.closest('.card-list');
    if (!cardList || dragSrcColIdx === null) return;
    cardList.classList.remove('drag-over');
    const destColIdx = parseInt(cardList.dataset.colIdx);
    const nv = JSON.parse(JSON.stringify(props.value));
    const card = nv.columns[dragSrcColIdx].cards.splice(dragSrcCardIdx, 1)[0];
    const cardElements = cardList.querySelectorAll('.kanban-card:not(.dragging)');
    let insertIdx = nv.columns[destColIdx].cards.length;
    for (let i = 0; i < cardElements.length; i++) {
        const rect = cardElements[i].getBoundingClientRect();
        if (e.clientY < rect.top + rect.height / 2) { insertIdx = i; break; }
    }
    nv.columns[destColIdx].cards.splice(insertIdx, 0, card);
    props.value = nv;
    trigger('change');
    dragSrcColIdx = null;
    dragSrcCardIdx = null;
});
element.addEventListener('click', (e) => {
    const delBtn = e.target.closest('.delete-card');
    if (!delBtn) return;
    e.stopPropagation();
    const colIdx = parseInt(delBtn.dataset.colIdx);
    const cardIdx = parseInt(delBtn.dataset.cardIdx);
    const nv = JSON.parse(JSON.stringify(props.value));
    nv.columns[colIdx].cards.splice(cardIdx, 1);
    props.value = nv;
    trigger('change');
});
element.addEventListener('click', (e) => {
    const btn = e.target.closest('.priority-cycle');
    if (!btn) return;
    e.stopPropagation();
    const colIdx = parseInt(btn.dataset.colIdx);
    const cardIdx = parseInt(btn.dataset.cardIdx);
    const nv = JSON.parse(JSON.stringify(props.value));
    const card = nv.columns[colIdx].cards[cardIdx];
    const cycle = { low: 'medium', medium: 'high', high: 'low' };
    card.priority = cycle[card.priority] || 'low';
    props.value = nv;
    trigger('change');
});
element.addEventListener('click', (e) => {
    const btn = e.target.closest('.collapse-btn');
    if (!btn) return;
    const colIdx = parseInt(btn.dataset.colIdx);
    const nv = JSON.parse(JSON.stringify(props.value));
    nv.columns[colIdx].collapsed = !nv.columns[colIdx].collapsed;
    props.value = nv;
    trigger('change');
});
element.addEventListener('dblclick', (e) => {
    const textEl = e.target.closest('.card-text');
    if (!textEl) return;
    textEl.contentEditable = 'true';
    textEl.classList.add('editing');
    textEl.focus();
    const range = document.createRange();
    range.selectNodeContents(textEl);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
});
function commitEdit(textEl) {
    textEl.contentEditable = 'false';
    textEl.classList.remove('editing');
    const colIdx = parseInt(textEl.dataset.colIdx);
    const cardIdx = parseInt(textEl.dataset.cardIdx);
    const newText = textEl.innerText.trim();
    if (!newText) return;
    const nv = JSON.parse(JSON.stringify(props.value));
    nv.columns[colIdx].cards[cardIdx].text = newText;
    props.value = nv;
    trigger('change');
}
element.addEventListener('blur', (e) => {
    if (e.target.classList && e.target.classList.contains('editing')) {
        commitEdit(e.target);
    }
}, true);
element.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && e.target.classList.contains('editing')) {
        e.preventDefault();
        e.target.blur();
        return;
    }
    if (e.key === 'Enter' && e.target.classList.contains('add-card-input')) {
        const text = e.target.value.trim();
        if (!text) return;
        const colIdx = parseInt(e.target.dataset.colIdx);
        const nv = JSON.parse(JSON.stringify(props.value));
        nv.columns[colIdx].cards.push({
            id: String(Date.now()),
            text: text,
            priority: 'medium',
            tags: []
        });
        props.value = nv;
        e.target.value = '';
        trigger('change');
    }
});
element.addEventListener('input', (e) => {
    if (!e.target.classList.contains('search-input')) return;
    const q = e.target.value.toLowerCase().trim();
    element.querySelectorAll('.kanban-card').forEach(card => {
        const text = card.querySelector('.card-text').innerText.toLowerCase();
        const tags = Array.from(card.querySelectorAll('.kb-tag')).map(t => t.innerText.toLowerCase()).join(' ');
        const match = !q || text.includes(q) || tags.includes(q);
        card.classList.toggle('search-hidden', !match);
        card.classList.toggle('search-highlight', !!q && match);
    });
});

📋 To Do

Research gr.HTML component

Design the UI layout

Write documentation

🔨 In Progress

Build Kanban prototype

👀 Review

✅ Done

Set up Gradio project

class KanbanBoard(gr.HTML):
    """A drag-and-drop Kanban board component."""

    def __init__(self, value=None, board_title="My Board", **kwargs):
        if value is None:
            value = {
                "columns": [
                    {
                        "id": "todo", "title": "To Do", "color": "#6366f1",
                        "cards": [
                            {"id": "1", "text": "Research gr.HTML", "priority": "high", "tags": ["gradio"]},
                            {"id": "2", "text": "Design UI layout", "priority": "medium", "tags": ["design"]},
                        ],
                    },
                    {
                        "id": "progress", "title": "In Progress", "color": "#f59e0b",
                        "cards": [
                            {"id": "3", "text": "Build prototype", "priority": "high", "tags": ["dev"]},
                        ],
                    },
                    {"id": "review", "title": "Review", "color": "#8b5cf6", "cards": []},
                    {
                        "id": "done", "title": "Done", "color": "#10b981",
                        "cards": [
                            {"id": "4", "text": "Set up project", "priority": "medium", "tags": ["setup"]},
                        ],
                    },
                ],
            }
        super().__init__(
            value=value, board_title=board_title,
            html_template=HTML_TEMPLATE,
            css_template=CSS_TEMPLATE,
            js_on_load=JS_ON_LOAD, **kwargs
        )

    def api_info(self):
        return {"type": "object", "description": "Kanban board state"}