Retention Heatmap
A cohort retention heatmap where the color intensity dynamically adjusts based on actual data values, and you have full control over the color scheme — combining the best of both built-in options.
Best for: Cohort retention analysis, user engagement tracking, subscription churn monitoring
Key techniques: Pivot-style map(columns) + nested map(values), CSS custom properties for dynamic color, hsl() color calculations
Why Use This Over Built-in Options?
Holistics already offers two ways to visualize cohort retention, but each comes with a trade-off:
| Approach | Custom Colors | Dynamic Color Scale | Maintenance |
|---|---|---|---|
| Pivot Table with Conditional Formatting | Yes | No | Low — built-in visualization |
| Built-in Retention Heatmap | No | Yes | Low — built-in visualization |
| Dynamic Content Block (this template) | Yes | Yes | Higher — you maintain the HTML/CSS yourself |
The trade-off: Since this is a custom HTML/CSS template, you're responsible for maintaining the structure and styling yourself. It won't automatically stay consistent with other built-in visualizations if Holistics updates its UI. Use this approach when the built-in options don't meet your needs. Otherwise, stick with the built-in Retention Heatmap or Pivot Table.
What "dynamic color scale" means: The color intensity adjusts automatically based on the values currently in the table. Without it, you run into problems:
- Across different users: You set the darkest color for max = 1000. Client A's max is 1000 and looks great. Client B's max is only 80 — their entire heatmap appears washed out.
- Across different time filters: Same user, same dashboard. With a "last year" filter, max = 1000 shows the darkest color. Switch to "last month", max drops to 200, but it's displayed in a pale shade because the scale was designed for 1000.
This template solves both by using a CSS-calculated color scale driven by a Heat Intensity field that you normalize in your data query (0 to 1 range), so the darkest color always maps to your current maximum.
Template Code
<style>
.cohort-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 12px;
overflow-x: auto;
padding: 1rem;
/* Centralized color scale settings — change these to customize */
--hue: 201;
--saturation: 96%;
}
.cohort-table {
border-collapse: collapse;
width: 100%;
min-width: 900px;
}
.cohort-table th,
.cohort-table td {
padding: 8px 12px;
text-align: center;
border: 1px solid #E5E7EB;
white-space: nowrap;
}
.cohort-table thead th {
background-color: #F9FAFB;
font-weight: 600;
color: #374151;
}
.cohort-table th.month-header {
min-width: 55px;
}
.cohort-table th.cohort-header {
text-align: left;
min-width: 120px;
}
.cohort-table th.size-header {
text-align: right;
min-width: 120px;
}
.cohort-table td.cohort-cell {
text-align: left;
font-weight: 500;
color: #111827;
background-color: #FFFFFF;
}
.cohort-table td.size-cell {
text-align: right;
color: #6B7280;
background-color: #FFFFFF;
}
/* Dynamic color scale for percentage cells */
.cohort-table td.percentage-cell {
--percentage: 0;
--bg-lightness: calc(95 - var(--percentage) * 50);
--text-lightness: calc(25 + (1 - var(--percentage)) * 30);
font-weight: 500;
min-width: 55px;
background-color: hsl(var(--hue) var(--saturation) calc(var(--bg-lightness) * 1%));
color: hsl(var(--hue) var(--saturation) calc(var(--text-lightness) * 1%));
}
.month-group-header {
text-align: center;
font-weight: 600;
color: #374151;
}
</style>
<div class="cohort-container">
<table class="cohort-table">
<thead>
<tr>
<th class="cohort-header" rowspan="2">Cohort Month</th>
<th class="size-header" rowspan="2">Cohort Size</th>
<th class="month-group-header" colspan="13">Month Number</th>
</tr>
<tr>
{% map(columns) %}
<th class="month-header">{{ `Month Number` }}</th>
{% end %}
</tr>
</thead>
<tbody>
{% map(rows) %}
<tr>
<td class="cohort-cell">{{ `Cohort Month` }}</td>
<td class="size-cell">{{ `Users Cohort Size` }}</td>
{% map(values) %}
<td class="percentage-cell" style="--percentage: {{ value.`Heat Intensity`.raw }};">
{{ value.`Total Users` }}
</td>
{% end %}
</tr>
{% end %}
</tbody>
</table>
</div>
How the Dynamic Color Scale Works
The color magic happens through CSS custom properties and hsl() calculations. Here's how the pieces fit together:
1. You set the base color once at the container level:
.cohort-container {
--hue: 201; /* Blue — change this to pick your color */
--saturation: 96%; /* Color richness */
}
2. Each cell receives a --percentage value (0 to 1) from your data via inline style:
<td class="percentage-cell" style="--percentage: {{ value.`Heat Intensity`.raw }};">
3. CSS calculates the lightness dynamically:
--bg-lightness: calc(95 - var(--percentage) * 50);
/* percentage = 0 → lightness 95% (nearly white) */
/* percentage = 1 → lightness 45% (deep color) */
background-color: hsl(var(--hue) var(--saturation) calc(var(--bg-lightness) * 1%));
The text color also adjusts — darker text on light cells, lighter text on dark cells — so values stay readable at any intensity.
Since --percentage comes from a normalized field in your query (value / max_value), the scale always maps your current data range to the full color spectrum, regardless of the absolute numbers.
Required Data Fields
| Field | Type | Slot | Description |
|---|---|---|---|
Cohort Month | Dimension | Row | The cohort period (e.g., "Jan 2024") |
Users Cohort Size | Dimension | Row | Number of users in the cohort |
Month Number | Dimension | Column (pivot) | Duration period (0, 1, 2, ...) |
Total Users | Measure | Value | Count of returning users per cell |
Heat Intensity | Measure | Value | Normalized value between 0 and 1 for color intensity |
Preparing the Heat Intensity Field
The Heat Intensity field drives the color scale. It should be a value between 0 and 1, where 1 = the maximum in your current data. A common approach:
-- Retention rate normalized to 0-1
retention_count / cohort_size
Or, if you want the intensity relative to the max value in the entire table:
(retention_count / cohort_size) / MAX(retention_count / cohort_size) OVER ()
This ensures the cell with the highest retention rate always gets the darkest color, and everything else scales proportionally.
Customization Tips
Change the color scheme
Adjust --hue and --saturation in .cohort-container to any color you want:
| Color | --hue | --saturation |
|---|---|---|
| Blue (default) | 201 | 96% |
| Green | 142 | 70% |
| Purple | 270 | 80% |
| Orange | 25 | 90% |
| Teal | 175 | 70% |
Adjust the intensity range
The lightness formula calc(95 - var(--percentage) * 50) means:
- 95 = lightest value (when percentage = 0)
- 50 = the range of lightness variation
To make the contrast more dramatic, increase the range (e.g., * 60). To make it subtler, decrease it (e.g., * 30).
Add row/column totals
You can add totals using the row_total and col_totals accessors from the Syntax Reference. For example, add a totals column at the end of each row:
<td><strong>{{ `row_total`.`Total Users` }}</strong></td>