Skip to main content

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

Retention Heatmap with Dynamic Color Intensity

Why Use This Over Built-in Options?

Holistics already offers two ways to visualize cohort retention, but each comes with a trade-off:

ApproachCustom ColorsDynamic Color ScaleMaintenance
Pivot Table with Conditional FormattingYesNoLow — built-in visualization
Built-in Retention HeatmapNoYesLow — built-in visualization
Dynamic Content Block (this template)YesYesHigher — 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

FieldTypeSlotDescription
Cohort MonthDimensionRowThe cohort period (e.g., "Jan 2024")
Users Cohort SizeDimensionRowNumber of users in the cohort
Month NumberDimensionColumn (pivot)Duration period (0, 1, 2, ...)
Total UsersMeasureValueCount of returning users per cell
Heat IntensityMeasureValueNormalized 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)20196%
Green14270%
Purple27080%
Orange2590%
Teal17570%

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>

Let us know what you think about this document :)