Stats Bars
Cohort retention heatmap
Shaded grid of weekly retention
Shaded grid of weekly retention
Create a cohort retention card with Tailwind CSS and no chart library. A bordered card with a title, a subline naming the metric, and a legend on the right made of five small squares stepping from light to solid with "Low" and "High" captions. The body is a horizontally scrollable table with border-separate spacing, a header row of "Cohort", "Users", and Week 0 through Week 6, and an empty tbody. Close with a footnote about how the cohort is defined. Then add JavaScript that fills the table from an array of cohorts, each with a signup month, a user count, and a retention curve: render one cell per week, shade it by writing an rgba background whose alpha tracks the percentage, flip the text to white once the cell is dark enough to need it, print the percentage inside the cell, leave future weeks as an empty dashed placeholder, and add a title tooltip converting the percentage back into a user count. <section class="mx-auto max-w-4xl px-6 py-16">
<div class="rounded-2xl border border-neutral-200 bg-white p-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-neutral-900">Weekly retention by cohort</h2>
<p class="mt-1 text-sm text-neutral-500">Share of each signup cohort still active in week N.</p>
</div>
<div class="flex items-center gap-2 text-xs text-neutral-400">
<span>Low</span>
<span class="h-3 w-4 rounded-sm bg-neutral-900/10"></span>
<span class="h-3 w-4 rounded-sm bg-neutral-900/25"></span>
<span class="h-3 w-4 rounded-sm bg-neutral-900/45"></span>
<span class="h-3 w-4 rounded-sm bg-neutral-900/70"></span>
<span class="h-3 w-4 rounded-sm bg-neutral-900"></span>
<span>High</span>
</div>
</div>
<div class="mt-6 overflow-x-auto">
<table class="w-full min-w-[680px] border-separate border-spacing-1 text-sm">
<thead>
<tr class="text-xs text-neutral-400">
<th class="px-2 py-1 text-left font-medium">Cohort</th>
<th class="px-2 py-1 text-right font-medium">Users</th>
<th class="px-2 py-1 text-center font-medium">W0</th>
<th class="px-2 py-1 text-center font-medium">W1</th>
<th class="px-2 py-1 text-center font-medium">W2</th>
<th class="px-2 py-1 text-center font-medium">W3</th>
<th class="px-2 py-1 text-center font-medium">W4</th>
<th class="px-2 py-1 text-center font-medium">W5</th>
<th class="px-2 py-1 text-center font-medium">W6</th>
</tr>
</thead>
<tbody id="cohorts"></tbody>
</table>
</div>
<p class="mt-5 border-t border-neutral-100 pt-4 text-xs text-neutral-400">
A user counts as retained in a week if they ran at least one build. Cohorts younger than seven weeks show blanks.
</p>
</div>
</section> // Percentages per week, newest cohort last. Short rows simply have fewer weeks of history.
var COHORTS = [
{ label: "Mar 2026", users: 1840, weeks: [100, 71, 62, 58, 55, 53, 52] },
{ label: "Apr 2026", users: 2310, weeks: [100, 74, 66, 61, 59, 57, 56] },
{ label: "May 2026", users: 2680, weeks: [100, 78, 70, 66, 63, 61] },
{ label: "Jun 2026", users: 3120, weeks: [100, 81, 73, 69, 66] },
{ label: "Jul 2026", users: 3540, weeks: [100, 83, 76, 71] },
{ label: "Aug 2026", users: 4010, weeks: [100, 85, 78] }
];
var WEEKS = 7;
var body = document.getElementById("cohorts");
function cell(value, users) {
var td = document.createElement("td");
if (value === undefined) {
td.className = "rounded-md border border-dashed border-neutral-200 px-2 py-2";
return td;
}
// Alpha never starts at zero, so even a weak week stays visible.
var alpha = 0.08 + (value / 100) * 0.92;
td.className = "rounded-md px-2 py-2 text-center text-xs font-medium " + (alpha > 0.55 ? "text-white" : "text-neutral-700");
td.style.backgroundColor = "rgba(23, 23, 23, " + alpha.toFixed(2) + ")";
td.textContent = value + "%";
td.title = Math.round((value / 100) * users).toLocaleString("en-US") + " of " + users.toLocaleString("en-US") + " users";
return td;
}
COHORTS.forEach(function (cohort) {
var row = document.createElement("tr");
var label = document.createElement("th");
label.className = "whitespace-nowrap px-2 py-2 text-left text-sm font-medium text-neutral-900";
label.textContent = cohort.label;
row.appendChild(label);
var size = document.createElement("td");
size.className = "px-2 py-2 text-right font-mono text-xs text-neutral-500";
size.textContent = cohort.users.toLocaleString("en-US");
row.appendChild(size);
for (var w = 0; w < WEEKS; w++) row.appendChild(cell(cohort.weeks[w], cohort.users));
body.appendChild(row);
});