UI Components Hard
Calendar Heatmap
A GitHub-style contribution calendar heatmap. Renders a full year of daily data cells with color intensity levels, week/month labels, and an interactive tooltip showing date and value on hover.
Open in Lab
MCP
vanilla-js svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--l0: #1e2130;
--l1: #2a3a5c;
--l2: #3b5998;
--l3: #6366f1;
--l4: #a5b4fc;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 900px;
margin: 0 auto;
}
.chart-header {
margin-bottom: 16px;
}
.chart-title {
font-size: 1.2rem;
font-weight: 700;
}
.chart-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 3px;
}
.heatmap-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
overflow-x: auto;
position: relative;
}
.heatmap-wrap svg {
display: block;
}
.hm-label {
fill: var(--text-muted);
font-size: 9px;
font-family: inherit;
}
.hm-cell-rect {
rx: 2;
cursor: pointer;
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 5px;
margin-top: 10px;
font-size: 0.72rem;
color: var(--text-muted);
}
.hm-cell {
width: 12px;
height: 12px;
border-radius: 2px;
}
.hm-lbl {
}
.chart-tooltip {
position: fixed;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 11px;
font-size: 0.78rem;
pointer-events: none;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}// Generate a year of random contribution data
const COLORS = ["#1e2130", "#2a3a5c", "#3b5998", "#6366f1", "#a5b4fc"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
const tooltip = document.getElementById("chartTooltip");
const svgEl = document.getElementById("heatmapSvg");
function level(v) {
if (v === 0) return 0;
if (v < 2) return 1;
if (v < 5) return 2;
if (v < 9) return 3;
return 4;
}
function draw() {
const CELL = 14,
GAP = 3,
STEP = CELL + GAP;
const today = new Date();
const start = new Date(today);
start.setFullYear(start.getFullYear() - 1);
// align to Sunday
start.setDate(start.getDate() - start.getDay());
const weeks = [];
const d = new Date(start);
while (d <= today) {
const week = [];
for (let i = 0; i < 7; i++) {
const val = d <= today ? Math.floor(Math.pow(Math.random(), 1.5) * 15) : -1;
week.push({ date: new Date(d), val });
d.setDate(d.getDate() + 1);
}
weeks.push(week);
}
const LEFT_PAD = 28,
TOP_PAD = 20,
BOTTOM_PAD = 8;
const W = weeks.length * STEP + LEFT_PAD;
const H = 7 * STEP + TOP_PAD + BOTTOM_PAD;
svgEl.setAttribute("viewBox", `0 0 ${W} ${H}`);
svgEl.style.width = Math.min(document.getElementById("heatmapWrap").clientWidth - 32, W) + "px";
svgEl.style.height = H * ((document.getElementById("heatmapWrap").clientWidth - 32) / W) + "px";
svgEl.innerHTML = "";
// Day labels (Mon / Wed / Fri)
DAY_LABELS.forEach((lbl, i) => {
if (!lbl) return;
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", LEFT_PAD - 6);
t.setAttribute("y", TOP_PAD + i * STEP + CELL - 1);
t.setAttribute("class", "hm-label");
t.setAttribute("text-anchor", "end");
t.textContent = lbl;
svgEl.appendChild(t);
});
// Month labels
let prevMonth = -1;
weeks.forEach((week, wi) => {
const month = week[0].date.getMonth();
if (month !== prevMonth) {
prevMonth = month;
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", LEFT_PAD + wi * STEP);
t.setAttribute("y", TOP_PAD - 6);
t.setAttribute("class", "hm-label");
t.textContent = MONTHS[month];
svgEl.appendChild(t);
}
});
// Cells
weeks.forEach((week, wi) => {
week.forEach((day, di) => {
if (day.val < 0) return;
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", LEFT_PAD + wi * STEP);
rect.setAttribute("y", TOP_PAD + di * STEP);
rect.setAttribute("width", CELL);
rect.setAttribute("height", CELL);
rect.setAttribute("rx", 2);
rect.setAttribute("fill", COLORS[level(day.val)]);
rect.setAttribute("class", "hm-cell-rect");
rect.addEventListener("mouseenter", (e) => {
const fmt = day.date.toLocaleDateString("en", {
weekday: "short",
month: "short",
day: "numeric",
});
tooltip.innerHTML = `<strong>${fmt}</strong><br/>${day.val} contribution${day.val !== 1 ? "s" : ""}`;
tooltip.hidden = false;
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
});
rect.addEventListener("mousemove", (e) => {
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
});
rect.addEventListener("mouseleave", () => (tooltip.hidden = true));
svgEl.appendChild(rect);
});
});
}
const wrap = document.getElementById("heatmapWrap");
const ro = new ResizeObserver(() => {
if (wrap.clientWidth > 0) draw();
});
ro.observe(wrap);
draw();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Calendar Heatmap</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<h1 class="chart-title">Contribution Activity</h1>
<p class="chart-sub">Last 12 months</p>
</div>
<div class="heatmap-wrap" id="heatmapWrap">
<svg id="heatmapSvg" aria-label="Calendar heatmap"></svg>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
</div>
<div class="heatmap-legend">
<span class="hm-lbl">Less</span>
<span class="hm-cell" style="background:var(--l0)"></span>
<span class="hm-cell" style="background:var(--l1)"></span>
<span class="hm-cell" style="background:var(--l2)"></span>
<span class="hm-cell" style="background:var(--l3)"></span>
<span class="hm-cell" style="background:var(--l4)"></span>
<span class="hm-lbl">More</span>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useMemo } from "react";
const COLORS = ["#1e2130", "#2a3a5c", "#3b5998", "#6366f1", "#a5b4fc"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
function level(v: number) {
if (v === 0) return 0;
if (v < 2) return 1;
if (v < 5) return 2;
if (v < 9) return 3;
return 4;
}
type Tooltip = { text: string; x: number; y: number } | null;
export default function ChartHeatmapRC() {
const [tooltip, setTooltip] = useState<Tooltip>(null);
const { weeks } = useMemo(() => {
const today = new Date();
const start = new Date(today);
start.setFullYear(start.getFullYear() - 1);
start.setDate(start.getDate() - start.getDay());
const weeks: { date: Date; val: number }[][] = [];
const d = new Date(start);
while (d <= today) {
const week: { date: Date; val: number }[] = [];
for (let i = 0; i < 7; i++) {
const val = d <= today ? Math.floor(Math.pow(Math.random(), 1.5) * 15) : -1;
week.push({ date: new Date(d), val });
d.setDate(d.getDate() + 1);
}
weeks.push(week);
}
return { weeks };
}, []);
const CELL = 12,
GAP = 2,
STEP = CELL + GAP;
const LEFT_PAD = 28,
TOP_PAD = 18;
const W = weeks.length * STEP + LEFT_PAD;
const H = 7 * STEP + TOP_PAD + 8;
// Month label positions
const monthLabels: { month: number; wi: number }[] = [];
let prevMonth = -1;
weeks.forEach((week, wi) => {
const m = week[0].date.getMonth();
if (m !== prevMonth) {
prevMonth = m;
monthLabels.push({ month: m, wi });
}
});
return (
<div className="min-h-screen bg-[#0d1117] p-6">
<div className="w-full max-w-[900px] mx-auto overflow-x-auto">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ minWidth: W }}>
{/* Day labels */}
{DAY_LABELS.map(
(lbl, i) =>
lbl && (
<text
key={i}
x={LEFT_PAD - 4}
y={TOP_PAD + i * STEP + CELL - 1}
textAnchor="end"
fill="#484f58"
fontSize={9}
>
{lbl}
</text>
)
)}
{/* Month labels */}
{monthLabels.map(({ month, wi }) => (
<text key={month} x={LEFT_PAD + wi * STEP} y={TOP_PAD - 5} fill="#484f58" fontSize={9}>
{MONTHS[month]}
</text>
))}
{/* Cells */}
{weeks.map((week, wi) =>
week.map((day, di) => {
if (day.val < 0) return null;
return (
<rect
key={`${wi}-${di}`}
x={LEFT_PAD + wi * STEP}
y={TOP_PAD + di * STEP}
width={CELL}
height={CELL}
rx={2}
fill={COLORS[level(day.val)]}
style={{ cursor: "pointer" }}
onMouseEnter={(e) => {
const fmt = day.date.toLocaleDateString("en", {
weekday: "short",
month: "short",
day: "numeric",
});
setTooltip({
text: `${fmt} — ${day.val} contribution${day.val !== 1 ? "s" : ""}`,
x: e.clientX,
y: e.clientY,
});
}}
onMouseMove={(e) =>
setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : null))
}
onMouseLeave={() => setTooltip(null)}
/>
);
})
)}
</svg>
{/* Legend */}
<div className="flex items-center gap-1.5 mt-3 justify-end">
<span className="text-[10px] text-[#484f58]">Less</span>
{COLORS.map((c, i) => (
<span key={i} className="w-3 h-3 rounded-sm" style={{ background: c }} />
))}
<span className="text-[10px] text-[#484f58]">More</span>
</div>
</div>
{tooltip && (
<div
className="fixed pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg z-50"
style={{ left: tooltip.x + 12, top: tooltip.y - 40 }}
>
<span className="text-[#e6edf3]">{tooltip.text}</span>
</div>
)}
</div>
);
}<script setup>
import { ref } from "vue";
const COLORS = ["#1e2130", "#2a3a5c", "#3b5998", "#6366f1", "#a5b4fc"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
function level(v) {
if (v === 0) return 0;
if (v < 2) return 1;
if (v < 5) return 2;
if (v < 9) return 3;
return 4;
}
const tooltip = ref(null);
// Generate weeks data
const today = new Date();
const start = new Date(today);
start.setFullYear(start.getFullYear() - 1);
start.setDate(start.getDate() - start.getDay());
const weeks = [];
const d = new Date(start);
while (d <= today) {
const week = [];
for (let i = 0; i < 7; i++) {
const val = d <= today ? Math.floor(Math.pow(Math.random(), 1.5) * 15) : -1;
week.push({ date: new Date(d), val });
d.setDate(d.getDate() + 1);
}
weeks.push(week);
}
const CELL = 12;
const GAP = 2;
const STEP = CELL + GAP;
const LEFT_PAD = 28;
const TOP_PAD = 18;
const W = weeks.length * STEP + LEFT_PAD;
const H = 7 * STEP + TOP_PAD + 8;
const monthLabels = [];
let prevMonth = -1;
weeks.forEach((week, wi) => {
const m = week[0].date.getMonth();
if (m !== prevMonth) {
prevMonth = m;
monthLabels.push({ month: m, wi });
}
});
function showTip(day, e) {
const fmt = day.date.toLocaleDateString("en", {
weekday: "short",
month: "short",
day: "numeric",
});
tooltip.value = {
text: `${fmt} — ${day.val} contribution${day.val !== 1 ? "s" : ""}`,
x: e.clientX,
y: e.clientY,
};
}
function moveTip(e) {
if (tooltip.value) tooltip.value = { ...tooltip.value, x: e.clientX, y: e.clientY };
}
function hideTip() {
tooltip.value = null;
}
</script>
<template>
<div class="page">
<div class="wrap">
<svg :width="W" :height="H" :viewBox="`0 0 ${W} ${H}`" :style="{ minWidth: W + 'px' }">
<template v-for="(lbl, i) in DAY_LABELS" :key="'day'+i">
<text v-if="lbl" :x="LEFT_PAD-4" :y="TOP_PAD+i*STEP+CELL-1" text-anchor="end" fill="#484f58" font-size="9">{{ lbl }}</text>
</template>
<text v-for="ml in monthLabels" :key="'m'+ml.month" :x="LEFT_PAD+ml.wi*STEP" :y="TOP_PAD-5" fill="#484f58" font-size="9">{{ MONTHS[ml.month] }}</text>
<template v-for="(week, wi) in weeks" :key="'w'+wi">
<template v-for="(day, di) in week" :key="`${wi}-${di}`">
<rect v-if="day.val >= 0"
:x="LEFT_PAD+wi*STEP" :y="TOP_PAD+di*STEP"
:width="CELL" :height="CELL" rx="2"
:fill="COLORS[level(day.val)]"
style="cursor:pointer"
@mouseenter="showTip(day, $event)"
@mousemove="moveTip"
@mouseleave="hideTip"
/>
</template>
</template>
</svg>
<div class="legend">
<span class="legend-text">Less</span>
<span v-for="(c, i) in COLORS" :key="i" class="legend-cell" :style="{ background: c }" />
<span class="legend-text">More</span>
</div>
</div>
<div v-if="tooltip" class="tip" :style="{ left: tooltip.x+12+'px', top: tooltip.y-40+'px' }">
<span class="tip-text">{{ tooltip.text }}</span>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; }
.wrap { width: 100%; max-width: 900px; margin: 0 auto; overflow-x: auto; }
.legend { display: flex; align-items: center; gap: 0.375rem; margin-top: 0.75rem; justify-content: flex-end; }
.legend-text { font-size: 10px; color: #484f58; }
.legend-cell { width: 12px; height: 12px; border-radius: 2px; }
.tip {
position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d;
border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50;
}
.tip-text { color: #e6edf3; }
</style><script>
import { onMount } from "svelte";
const COLORS = ["#1e2130", "#2a3a5c", "#3b5998", "#6366f1", "#a5b4fc"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
function level(v) {
if (v === 0) return 0;
if (v < 2) return 1;
if (v < 5) return 2;
if (v < 9) return 3;
return 4;
}
let tooltip = null;
// Generate weeks data
const today = new Date();
const start = new Date(today);
start.setFullYear(start.getFullYear() - 1);
start.setDate(start.getDate() - start.getDay());
const weeks = [];
const d = new Date(start);
while (d <= today) {
const week = [];
for (let i = 0; i < 7; i++) {
const val = d <= today ? Math.floor(Math.pow(Math.random(), 1.5) * 15) : -1;
week.push({ date: new Date(d), val });
d.setDate(d.getDate() + 1);
}
weeks.push(week);
}
const CELL = 12,
GAP = 2,
STEP = CELL + GAP;
const LEFT_PAD = 28,
TOP_PAD = 18;
const W = weeks.length * STEP + LEFT_PAD;
const H = 7 * STEP + TOP_PAD + 8;
// Month label positions
const monthLabels = [];
let prevMonth = -1;
weeks.forEach((week, wi) => {
const m = week[0].date.getMonth();
if (m !== prevMonth) {
prevMonth = m;
monthLabels.push({ month: m, wi });
}
});
function showTip(day, e) {
const fmt = day.date.toLocaleDateString("en", {
weekday: "short",
month: "short",
day: "numeric",
});
tooltip = {
text: `${fmt} — ${day.val} contribution${day.val !== 1 ? "s" : ""}`,
x: e.clientX,
y: e.clientY,
};
}
function moveTip(e) {
if (tooltip) tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
}
function hideTip() {
tooltip = null;
}
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; }
.wrap { width: 100%; max-width: 900px; margin: 0 auto; overflow-x: auto; }
.legend { display: flex; align-items: center; gap: 0.375rem; margin-top: 0.75rem; justify-content: flex-end; }
.legend-text { font-size: 10px; color: #484f58; }
.legend-cell { width: 12px; height: 12px; border-radius: 2px; }
.tip {
position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d;
border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50;
}
.tip-text { color: #e6edf3; }
</style>
<div class="page">
<div class="wrap">
<svg width={W} height={H} viewBox="0 0 {W} {H}" style="min-width:{W}px">
{#each DAY_LABELS as lbl, i}
{#if lbl}
<text x={LEFT_PAD-4} y={TOP_PAD+i*STEP+CELL-1} text-anchor="end" fill="#484f58" font-size="9">{lbl}</text>
{/if}
{/each}
{#each monthLabels as ml}
<text x={LEFT_PAD+ml.wi*STEP} y={TOP_PAD-5} fill="#484f58" font-size="9">{MONTHS[ml.month]}</text>
{/each}
{#each weeks as week, wi}
{#each week as day, di}
{#if day.val >= 0}
<rect
x={LEFT_PAD+wi*STEP} y={TOP_PAD+di*STEP}
width={CELL} height={CELL} rx="2"
fill={COLORS[level(day.val)]}
style="cursor:pointer"
on:mouseenter={e => showTip(day, e)}
on:mousemove={moveTip}
on:mouseleave={hideTip}
/>
{/if}
{/each}
{/each}
</svg>
<div class="legend">
<span class="legend-text">Less</span>
{#each COLORS as c}
<span class="legend-cell" style="background:{c}"></span>
{/each}
<span class="legend-text">More</span>
</div>
</div>
{#if tooltip}
<div class="tip" style="left:{tooltip.x+12}px; top:{tooltip.y-40}px;">
<span class="tip-text">{tooltip.text}</span>
</div>
{/if}
</div>Features
- Full year view — 52 weeks × 7 days rendered as small SVG cells
- 5 intensity levels — color scale from 0 activity to max
- Month labels — correctly positioned month boundaries
- Day labels — Mon/Wed/Fri labels on the left axis
- Hover tooltip — date + value shown on cell hover
- Responsive — scales to container width
How it works
- A year of dates is generated and bucketed by ISO week number
- Each cell is an SVG
<rect>withfillmapped to a 5-stop color scale titleelements provide accessible hover text- Month boundary positions are computed by counting week-column changes