Heatmaps & scroll
Three layers over the same page: where visitors clicked, where their attention went, and how far down they ever got. They are returned together because each one is misleading without the others.
Why three layers
- Clicks say where people acted. On their own they flatter whatever is already clickable.
- Attention — binned pointer position — says where people looked. A bright region here with nothing in the click layer is something visitors read and could not act on, which is usually the most actionable thing a heatmap says.
- Scroll reach says how much of the page was ever on screen. Without it, a cold region below the fold reads as ignored when it was never seen.
The grid
Both map layers are binned onto the same 48 × 72 grid, so they scale to one canvas without either needing to know how the other was produced. Horizontal position is a fraction of the viewport width; vertical position is a fraction of the full document height, not the viewport. A click 20% down a long page is in the same place for every visitor; 400 pixels down is not.
Viewport buckets
A page is only comparable within a viewport class, so every measurement is filed under one and the bucket is part of the query rather than something you can average away.
| Name | Type | Required | Description |
|---|---|---|---|
| mobile | width < 768 | No | Includes a landscape phone — it is still a phone as far as the layout is concerned. |
| tablet | 768 – 1199 | No | |
| desktop | ≥ 1200 | No | The default when the viewport width is unknown. |
Fetch a heatmap
GET /v1/analytics/heatmap?path=/pricing&bucket=desktop&days=30| Name | Type | Required | Description |
|---|---|---|---|
| path | string | Yes | The page. Normalised the same way ingest normalises it, so /Pricing/ and /pricing?ref=x both resolve. |
| bucket | string | No | mobile | tablet | desktop. Defaults to desktop. |
| days / from / to | window | No | Applies to the click layer. Attention and scroll are lifetime accumulations — see below. |
{
"data": {
"path": "/pricing",
"viewport_bucket": "desktop",
"grid_x": 48,
"grid_y": 72,
"clicks": [ { "grid_x": 22, "grid_y": 31, "clicks": 184, "rage_clicks": 3,
"top_target": "Book a demo" } ],
"pointer": [ { "grid_x": 22, "grid_y": 30, "samples": 9120 } ],
"scroll": [ { "bin": 0, "depth_pct": 0, "sessions": 1840, "reach_pct": 100.0 },
{ "bin": 10, "depth_pct": 50, "sessions": 1104, "reach_pct": 60.0 } ]
}
}Drawing it
Scale intensity against the busiest cell in the response rather than an absolute count, so a quiet page still produces a readable map, and use a square-root ramp — one runaway cell otherwise flattens everything else to invisible.
const peak = Math.max(...cells.map(c => c.clicks), 1)
for (const cell of cells) {
const intensity = Math.sqrt(cell.clicks / peak)
ctx.fillStyle = heatColor(intensity)
ctx.fillRect(
(cell.grid_x / 48) * width,
(cell.grid_y / 72) * height,
width / 48,
height / 72,
)
}Reading the scroll profile
Twenty-one bins at 5% intervals, each counting sessions that reached at least that far — so the series only ever falls. reach_pct is normalised against bin 0, which is every session that opened the page. The steepest drop is your fold line, and it is rarely where the design put it.
Rage and dead clicks
rage_clicks on a cell counts clicks that arrived three or more in a second in the same small area. Dead clicks — a click that hit nothing interactive — are counted in the cell total and reported separately in the overview. Both are worth reading as design defects rather than visitor behaviour: they mark things that look clickable and are not.