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.

NameTypeRequiredDescription
mobilewidth < 768NoIncludes a landscape phone — it is still a phone as far as the layout is concerned.
tablet768 – 1199No
desktop≥ 1200NoThe default when the viewport width is unknown.

Fetch a heatmap

bash
GET /v1/analytics/heatmap?path=/pricing&bucket=desktop&days=30
NameTypeRequiredDescription
pathstringYesThe page. Normalised the same way ingest normalises it, so /Pricing/ and /pricing?ref=x both resolve.
bucketstringNomobile | tablet | desktop. Defaults to desktop.
days / from / towindowNoApplies to the click layer. Attention and scroll are lifetime accumulations — see below.
json
{
  "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 } ]
  }
}
Warning:The attention and scroll layers are running totals per page and viewport, not windowed — they are accumulated at write time so a busy page costs one row per occupied cell rather than one per sample. The window narrows the click layer only. Compare click density between periods; compare attention and reach against the page's own history.

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.

js
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.