Gradio custom HTML component: Kanban Board
Author: gradio
Category: input
Tags: input, kanban, drag-drop, project-management
Description: Drag-and-drop Kanban board with inline editing, priority labels, and search

Head:

HTML template:
<div class="kanban-wrapper">
    <div class="kanban-header">
        <h2>${board_title}</h2>
        <div class="header-right">
            <div class="search-box">
                <span class="search-icon">🔍</span>
                <input type="text" class="search-input" placeholder="Search cards..." />
            </div>
            <div class="header-stats">
                ${(() => {
                    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 '<span class="stat-pill">📊 ' + total + ' tasks</span>' +
                           '<span class="stat-pill done-pill">✅ ' + doneCount + ' done (' + pct + '%)</span>';
                })()}
            </div>
        </div>
    </div>
    <div class="kb-progress-track">
        ${(() => {
            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 '<div class="kb-progress-bar" style="width: ' + pct + '%"></div>';
        })()}
    </div>
    <div class="kanban-board">
        ${((value && value.columns) || []).map((col, colIdx) => `
            <div class="kanban-column ${col.collapsed ? 'collapsed' : ''}" data-col-idx="${colIdx}" data-col-id="${col.id}">
                <div class="column-header" style="border-top: 3px solid ${col.color}">
                    <div class="col-header-left">
                        <button class="collapse-btn" data-col-idx="${colIdx}">${col.collapsed ? '▶' : '▼'}</button>
                        <span class="col-title">${col.title}</span>
                    </div>
                    <span class="col-count" style="background: ${col.color}22; color: ${col.color}">${col.cards.length}</span>
                </div>
                <div class="card-list ${col.collapsed ? 'hidden' : ''}" data-col-idx="${colIdx}">
                    ${col.cards.map((card, cardIdx) => `
                        <div class="kanban-card" draggable="true" data-col-idx="${colIdx}" data-card-idx="${cardIdx}" data-card-id="${card.id}">
                            <div class="card-priority priority-${card.priority}"></div>
                            <div class="card-content">
                                <div class="card-text" data-col-idx="${colIdx}" data-card-idx="${cardIdx}">${card.text}</div>
                                <div class="card-footer">
                                    <div class="card-tags">
                                        ${(card.tags || []).map(t => '<span class="kb-tag">' + t + '</span>').join('')}
                                    </div>
                                    <div class="card-actions">
                                        <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>
                                    </div>
                                </div>
                            </div>
                        </div>
                    `).join('')}
                </div>
                <div class="add-card-area ${col.collapsed ? 'hidden' : ''}">
                    <input type="text" class="add-card-input" data-col-idx="${colIdx}" placeholder="+ Add a card…  ⏎" />
                </div>
            </div>
        `).join('')}
    </div>
</div>

CSS:
.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); } }

JavaScript (on load):
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);
    });
});

Python code:
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"}