Gradio custom HTML component: Contribution Heatmap
Author: ysharma
Category: Display
Tags: Business
Description: Reusable GitHub-style contribution heatmap

Head:

HTML template:

<div class="heatmap-container">
    <div class="heatmap-header">
        <h2>${(() => {
            const total = Object.values(value || {}).reduce((a, b) => a + b, 0);
            return '📊 ' + total.toLocaleString() + ' contributions in ' + year;
        })()}</h2>
        <div class="legend">
            <span>Less</span>
            <div class="legend-box" style="background:${c0}"></div>
            <div class="legend-box" style="background:${c1}"></div>
            <div class="legend-box" style="background:${c2}"></div>
            <div class="legend-box" style="background:${c3}"></div>
            <div class="legend-box" style="background:${c4}"></div>
            <span>More</span>
        </div>
    </div>
    <div class="month-labels">
        ${(() => {
            const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
            return months.map((m, i) =>
                '<span style="grid-column:' + (Math.floor(i * 4.33) + 2) + '">' + m + '</span>'
            ).join('');
        })()}
    </div>
    <div class="heatmap-grid">
        <div class="day-labels">
            <span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span>
        </div>
        <div class="cells">
            ${(() => {
                const v = value || {};
                const sd = new Date(year, 0, 1);
                const pad = sd.getDay();
                const cells = [];
                for (let i = 0; i < pad; i++) cells.push('<div class="cell empty"></div>');
                const totalDays = Math.floor((new Date(year, 11, 31) - sd) / 86400000) + 1;
                for (let d = 0; d < totalDays; d++) {
                    const dt = new Date(year, 0, 1 + d);
                    const key = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0');
                    const count = v[key] || 0;
                    let lv = 0;
                    if (count > 0) lv = 1;
                    if (count >= 3) lv = 2;
                    if (count >= 6) lv = 3;
                    if (count >= 10) lv = 4;
                    const mn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
                    const tip = count + ' contributions on ' + mn[dt.getMonth()] + ' ' + dt.getDate() + ', ' + year;
                    cells.push('<div class="cell level-' + lv + '" data-date="' + key + '" data-count="' + count + '" title="' + tip + '"></div>');
                }
                return cells.join('');
            })()}
        </div>
    </div>
    <div class="stats-bar">
        ${(() => {
            const v = value || {};
            const totalDays = Math.floor((new Date(year, 11, 31) - new Date(year, 0, 1)) / 86400000) + 1;
            let streak = 0, maxStreak = 0, total = 0, active = 0, best = 0;
            const vals = [];
            for (let d = 0; d < totalDays; d++) {
                const dt = new Date(year, 0, 1 + d);
                const key = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0');
                const c = v[key] || 0;
                total += c;
                if (c > 0) { streak++; maxStreak = Math.max(maxStreak, streak); active++; best = Math.max(best, c); vals.push(c); }
                else { streak = 0; }
            }
            const avg = vals.length ? (total / vals.length).toFixed(1) : '0';
            const stats = [
                ['🔥', maxStreak, 'Longest Streak'],
                ['📅', active, 'Active Days'],
                ['⚡', best, 'Best Day'],
                ['📈', avg, 'Avg / Active Day'],
                ['🏆', total.toLocaleString(), 'Total'],
            ];
            return stats.map(s =>
                '<div class="stat"><span class="stat-value">' + s[1] + '</span><span class="stat-label">' + s[0] + ' ' + s[2] + '</span></div>'
            ).join('');
        })()}
    </div>
</div>


CSS:

    .heatmap-container {
        background: #0d1117;
        border-radius: 12px;
        padding: 24px;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
        color: #c9d1d9;
        overflow-x: auto;
    }
    .heatmap-header {
        display: flex; justify-content: space-between; align-items: center;
        margin-bottom: 12px; flex-wrap: wrap; gap: 10px;
    }
    .heatmap-header h2 { margin: 0; font-size: 16px; font-weight: 500; color: #f0f6fc; }
    .legend { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #8b949e; }
    .legend-box { width: 12px; height: 12px; border-radius: 2px; }
    .month-labels {
        display: grid; grid-template-columns: 30px repeat(53, 1fr);
        font-size: 11px; color: #8b949e; margin-bottom: 4px; padding-left: 2px;
    }
    .heatmap-grid { display: flex; gap: 4px; }
    .day-labels {
        display: grid; grid-template-rows: repeat(7, 1fr);
        font-size: 11px; color: #8b949e; width: 30px; gap: 2px;
    }
    .day-labels span { height: 13px; display: flex; align-items: center; }
    .cells {
        display: grid; grid-template-rows: repeat(7, 1fr);
        grid-auto-flow: column; gap: 2px; flex: 1;
    }
    .cell {
        width: 13px; height: 13px; border-radius: 2px; cursor: pointer;
        transition: all 0.15s ease; outline: 1px solid rgba(27,31,35,0.06);
    }
    .cell:hover {
        outline: 2px solid #58a6ff; outline-offset: -1px;
        transform: scale(1.3); z-index: 1;
    }
    .cell.empty { visibility: hidden; }
    .level-0 { background: ${c0}; }
    .level-1 { background: ${c1}; }
    .level-2 { background: ${c2}; }
    .level-3 { background: ${c3}; }
    .level-4 { background: ${c4}; }
    .stats-bar {
        display: flex; justify-content: space-around; margin-top: 20px;
        padding-top: 16px; border-top: 1px solid #21262d;
        flex-wrap: wrap; gap: 12px;
    }
    .stat { display: flex; flex-direction: column; align-items: center; gap: 4px; }
    .stat-value { font-size: 22px; font-weight: 700; color: ${c4}; }
    .stat-label { font-size: 12px; color: #8b949e; }


JavaScript (on load):

    element.addEventListener('click', (e) => {
        if (e.target && e.target.classList.contains('cell') && !e.target.classList.contains('empty')) {
            const date = e.target.dataset.date;
            const cur = parseInt(e.target.dataset.count) || 0;
            const next = cur >= 12 ? 0 : cur + 1;
            const nv = {...(props.value || {})};
            if (next === 0) delete nv[date]; else nv[date] = next;
            props.value = nv;
            trigger('change');
        }
    });


Python code:
class ContributionHeatmap(gr.HTML):
    """Reusable GitHub-style contribution heatmap built on gr.HTML."""

    def __init__(self, value=None, year=2025, theme="green",
                 c0=None, c1=None, c2=None, c3=None, c4=None, **kwargs):
        if value is None:
            value = {}
        # Use explicit c0-c4 if provided (from gr.HTML updates), else derive from theme
        colors = COLOR_SCHEMES.get(theme, COLOR_SCHEMES["green"])
        super().__init__(
            value=value,
            year=year,
            c0=c0 or colors[0],
            c1=c1 or colors[1],
            c2=c2 or colors[2],
            c3=c3 or colors[3],
            c4=c4 or colors[4],
            html_template=HEATMAP_HTML,
            css_template=HEATMAP_CSS,
            js_on_load=HEATMAP_JS,
            **kwargs,
        )

    def api_info(self):
        return {"type": "object", "description": "Dict mapping YYYY-MM-DD to int counts"}