Resizable heat-map that re-flows
A GitHub-style contribution calendar: drag the handle and the weeks wrap into bands while 371 day squares glide to their new spots (data-⚓️-fill + data-⚓️-transition="glide", one scoped --day name per cell).
<header class="intro">
<h1>A heat-map that re-flows</h1>
<p>
Drag the handle (or focus it and use the arrow keys). The weeks are a plain wrapping CSS grid
that re-flows <em>instantly</em>; every day square is anchored to its invisible cell with
<code>data-⚓️-fill</code> + <code>data-⚓️-transition="glide"</code> and slides to its new band.
Month labels ride <code>data-⚓️-side="above"</code> their first week, the handle sits
<code>data-⚓️-side="after"</code> the frame, and the tooltip glides between days because
hovering just moves the <code>--hot</code> anchor name (near the right edge
<code>data-⚓️-try="flip-x"</code> flips it to end-align). JS only builds the data and tracks the pointer.
</p>
<div class="controls">
<p class="total" id="total"> </p>
<label class="xray-toggle"><input type="checkbox" name="xray"> X-ray</label>
</div>
</header>
<div class="stage" data-⚓️-root>
<section class="frame" data-⚓️-name="--frame" aria-labelledby="total">
<div class="weeks" id="weeks" tabindex="0" aria-label="Daily contributions. Arrow keys move between days." aria-describedby="tip"></div>
<div class="legend" aria-hidden="true">
Less <i data-l="0"></i><i data-l="1"></i><i data-l="2"></i><i data-l="3"></i><i data-l="4"></i> More
</div>
</section>
<button class="handle" type="button" data-⚓️-to="--frame" data-⚓️-side="after" data-⚓️-align-y="center" aria-label="Resize heat-map (left and right arrow keys)"></button>
<div id="months"></div>
<p class="tip" id="tip" role="status" data-⚓️-to="--hot" data-⚓️-side="above" data-⚓️-align-leading data-⚓️-try="flip-x" data-⚓️-transition="glide"><span class="tip-text"></span></p>
</div>/* Demo styling only. Squares, month labels, the handle and the tooltip are
all placed by data-⚓️-* attributes; nothing here sets a position. */
:root {
--sq: 14px;
--gap: 3px;
--l0: oklch(26% 0.015 160);
--l1: oklch(42% 0.08 160);
--l2: oklch(56% 0.12 158);
--l3: oklch(70% 0.15 155);
--l4: oklch(86% 0.17 150);
color-scheme: dark;
background: oklch(15% 0.01 200);
color: oklch(91% 0.01 200);
font: 400 1rem/1.5 system-ui, sans-serif;
}
body {
margin: 0;
padding: clamp(1rem, 4vw, 3rem);
}
.intro {
max-width: 64ch;
margin-block-end: 1.5rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.4rem, 3vw, 1.9rem);
font-weight: 650;
letter-spacing: -0.02em;
}
.intro p {
margin: 0;
color: oklch(72% 0.015 200);
text-wrap: pretty;
}
code {
font: 0.88em ui-monospace, monospace;
color: var(--l3);
white-space: nowrap;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1.25rem;
margin-block-start: 1.25rem;
}
.intro .total {
color: oklch(91% 0.01 200);
font-weight: 600;
}
.xray-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.875rem;
color: oklch(75% 0.015 200);
cursor: pointer;
}
.xray-toggle input { accent-color: var(--l3); }
/* ---------- layout (all of this reflows instantly) ---------- */
.stage {
/* room for the handle, which sits after the frame */
padding-inline-end: 1.75rem;
}
.frame {
inline-size: min(100%, calc(53 * (var(--sq) + var(--gap)) + 2.5rem));
min-inline-size: 160px;
box-sizing: border-box;
padding: 0.5rem 1.25rem 1rem;
border: 1px solid oklch(30% 0.015 200);
border-radius: 1rem;
background: oklch(18% 0.012 200);
}
.weeks {
display: grid;
grid-template-columns: repeat(auto-fill, var(--sq));
column-gap: var(--gap);
row-gap: 1.6rem; /* each wrapped band gets room for its month labels */
padding-block-start: 1.4rem;
border-radius: 0.25rem;
}
.weeks:focus-visible {
outline: 2px solid var(--l3);
outline-offset: 6px;
}
.week {
display: grid;
grid-template-rows: repeat(7, var(--sq));
row-gap: var(--gap);
}
/* ---------- the visible pieces (looks only) ---------- */
.sq {
border-radius: 3px;
background: var(--l0);
transition-duration: 0.4s;
}
.sq[data-l="1"] { background: var(--l1); }
.sq[data-l="2"] { background: var(--l2); }
.sq[data-l="3"] { background: var(--l3); }
.sq[data-l="4"] { background: var(--l4); }
.sq[data-pad] { opacity: 0.25; }
.sq[data-⚓️-name="--hot"] { box-shadow: 0 0 0 2px oklch(98% 0 0); }
.month {
padding-block-end: 0.2rem;
font-size: 0.72rem;
color: oklch(70% 0.02 200);
white-space: nowrap;
transition-duration: 0.4s;
}
.legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--gap);
margin-block-start: 1rem;
font-size: 0.72rem;
color: oklch(65% 0.02 200);
}
.legend i {
inline-size: var(--sq);
block-size: var(--sq);
border-radius: 3px;
background: var(--l0);
}
.legend i[data-l="1"] { background: var(--l1); }
.legend i[data-l="2"] { background: var(--l2); }
.legend i[data-l="3"] { background: var(--l3); }
.legend i[data-l="4"] { background: var(--l4); }
.legend i:first-of-type { margin-inline-start: 0.25rem; }
.legend i:last-of-type { margin-inline-end: 0.25rem; }
.handle {
inline-size: 0.75rem;
block-size: 3.5rem;
margin-inline-start: 0.5rem;
padding: 0;
border: 0;
border-radius: 999px;
background: oklch(40% 0.02 200);
cursor: ew-resize;
touch-action: none;
transition: background-color 0.2s;
}
.handle:hover,
.handle:focus-visible { background: var(--l3); }
.handle:focus-visible {
outline: 2px solid var(--l3);
outline-offset: 3px;
}
.tip {
margin: 0 0 0.5rem;
padding: 0.35rem 0.6rem;
border-radius: 0.5rem;
background: oklch(96% 0.01 200);
color: oklch(20% 0.01 200);
font-size: 0.78rem;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 8px 20px -8px oklch(0% 0 0 / 0.7);
z-index: 1;
transition-duration: 0.3s;
}
/* The notch points at the day: at the tip's start edge normally, at its end
edge once data-⚓️-try has flipped it (the library makes every target an
anchored query container, so the notch can ask which fallback won). */
.tip-text::before {
content: "";
position: absolute;
inset-block-start: 100%;
inset-inline-start: calc(var(--sq) / 2 - 5px);
border: 5px solid transparent;
border-block-start-color: oklch(96% 0.01 200);
}
@container anchored(fallback: flip-inline) {
.tip-text::before {
inset-inline-start: auto;
inset-inline-end: calc(var(--sq) / 2 - 5px);
}
}
/* ---------- X-ray: outline anchors, show the tooltip's live side ---------- */
body:has([name="xray"]:checked) {
& [data-⚓️-name]:not(.sq) {
outline: 1px dashed oklch(80% 0.15 330);
outline-offset: 1px;
}
& .day {
outline: 1px dotted oklch(80% 0.1 230 / 0.6);
}
& .sq { opacity: 0.55; }
& .handle::after,
& .tip::after {
content: " side=" attr(data-⚓️-side);
font: 0.62rem ui-monospace, monospace;
color: oklch(80% 0.15 330);
}
& .tip::after { color: oklch(55% 0.2 330); }
/* Flag a data-⚓️-try flip without changing the tip's size (extra text
would make the flipped tip wider, so it wouldn't fit either way and the
browser would refuse the flip). */
@container anchored(fallback: flip-inline) {
& .tip-text {
text-decoration: underline wavy oklch(60% 0.22 330);
text-underline-offset: 3px;
}
}
& .handle::after {
position: absolute;
inset-block-start: 100%;
inset-inline-end: 0;
white-space: nowrap;
}
}
@media (prefers-reduced-motion: reduce) {
.handle { transition: none; }
}// Builds a year of fake data and handles pointer/keyboard state.
// No position maths: every square, label, handle and tooltip is placed by
// data-⚓️-* attributes. The handle only changes the frame's width.
const weeksEl = document.getElementById("weeks");
const monthsEl = document.getElementById("months");
const tip = document.getElementById("tip");
const tipText = tip.querySelector(".tip-text");
const frame = document.querySelector(".frame");
const handle = document.querySelector(".handle");
// ---------- data (seeded, so it looks the same every load) ----------
let seed = 7;
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const fmt = new Intl.DateTimeFormat("en", { weekday: "short", month: "short", day: "numeric" });
const monthFmt = new Intl.DateTimeFormat("en", { month: "short" });
const start = new Date(2024, 11, 29); // the Sunday before 1 Jan 2025
const days = [];
let total = 0;
for (let w = 0; w < 53; w++) {
const week = document.createElement("div");
week.className = "week";
for (let d = 0; d < 7; d++) {
const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d);
const inYear = date.getFullYear() === 2025;
const weekend = d === 0 || d === 6;
const burst = Math.sin(w / 4.3) * 0.5 + 0.5;
const r = rand();
const count = !inYear ? 0 : Math.max(0, Math.round((weekend ? 1.5 : 6) * burst * r * 2.2 + (r > 0.93 ? 9 : 0) - 1));
total += count;
const level = count === 0 ? 0 : count < 3 ? 1 : count < 6 ? 2 : count < 9 ? 3 : 4;
if (inYear && date.getDate() === 1) {
// the week holding the 1st becomes that month's anchor
const name = `--m${date.getMonth()}`;
week.setAttribute("data-⚓️-name", name);
const label = document.createElement("span");
label.className = "month";
label.textContent = monthFmt.format(date);
label.setAttribute("aria-hidden", "true");
label.setAttribute("data-⚓️-to", name);
label.setAttribute("data-⚓️-side", "above");
label.setAttribute("data-⚓️-align-leading", "");
label.setAttribute("data-⚓️-transition", "glide");
monthsEl.append(label);
}
const cell = document.createElement("div");
cell.className = "day";
cell.setAttribute("data-⚓️-name", "--day");
cell.setAttribute("data-⚓️-scope-name", "--day");
const sq = document.createElement("span");
sq.className = "sq";
sq.dataset.l = level;
if (!inYear) sq.dataset.pad = "";
sq.setAttribute("data-⚓️-to", "--day");
sq.setAttribute("data-⚓️-fill", "");
sq.setAttribute("data-⚓️-transition", "glide");
sq.dataset.label = `${count === 0 ? "No" : count} contribution${count === 1 ? "" : "s"} · ${fmt.format(date)}`;
cell.append(sq);
week.append(cell);
days.push(sq);
}
weeksEl.append(week);
}
document.getElementById("total").textContent = `${total.toLocaleString("en")} contributions in 2025`;
// ---------- the tooltip: move the --hot anchor name, nothing else ----------
let hot = null;
function setHot(sq) {
if (hot === sq) return;
hot?.removeAttribute("data-⚓️-name");
hot = sq;
if (!sq) { tip.hidden = true; return; }
sq.setAttribute("data-⚓️-name", "--hot");
tipText.textContent = sq.dataset.label;
tip.hidden = false;
}
tip.hidden = true;
weeksEl.addEventListener("pointerover", (e) => { if (e.target.matches(".sq:not([data-pad])")) setHot(e.target); });
weeksEl.addEventListener("pointerleave", () => { if (document.activeElement !== weeksEl) setHot(null); });
weeksEl.addEventListener("focus", () => setHot(hot ?? days.find((d) => !("pad" in d.dataset))));
weeksEl.addEventListener("blur", () => setHot(null));
weeksEl.addEventListener("keydown", (e) => {
const step = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 }[e.key];
if (!step || !hot) return;
e.preventDefault();
const next = days[days.indexOf(hot) + step];
if (next && !("pad" in next.dataset)) setHot(next);
});
// ---------- the handle: pointer drag / arrow keys change the frame width ----------
const minW = 160;
const setWidth = (w) => {
frame.style.inlineSize = `${Math.max(minW, Math.min(w, frame.parentElement.clientWidth - 24))}px`;
};
handle.addEventListener("pointerdown", (e) => {
handle.setPointerCapture(e.pointerId);
const x0 = e.clientX, w0 = frame.offsetWidth;
const dir = getComputedStyle(frame).direction === "rtl" ? -1 : 1;
const move = (ev) => setWidth(w0 + (ev.clientX - x0) * dir);
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", () => handle.removeEventListener("pointermove", move), { once: true });
});
handle.addEventListener("keydown", (e) => {
const d = { ArrowLeft: -40, ArrowRight: 40 }[e.key];
if (!d) return;
e.preventDefault();
setWidth(frame.offsetWidth + d);
});data-⚓️-align-leadingdata-⚓️-align-ydata-⚓️-filldata-⚓️-namedata-⚓️-rootdata-⚓️-scope-namedata-⚓️-sidedata-⚓️-todata-⚓️-transitiondata-⚓️-try
iOS app shell: nav bar, tab bar, push, sheet and split view →