Heatmaps
Aggregated click positions and scroll depth for any page, drawn on the page as it actually looked — rebuilt from a session recording, not framed from the live site.
Overview
A heatmap is three separate things, and most tools blur them together:
| What | Question it answers | Where it comes from |
|---|---|---|
| Click map | Where on the page do people click? | GET /client/v1/data/heatmap |
| Scroll map | How far down does the audience actually get? | GET /client/v1/data/heatmap/scroll |
| Background | What did the page look like? | GET /client/v1/data/heatmap/snapshot |
| Page list | Which pages have data at all? | GET /client/v1/data/heatmap/pages |
| Filter values | Which devices/sources reached this page? | GET /client/v1/data/heatmap/facets |
They are separate endpoints because they fail separately. A page with no recording still has click data worth reading, and a page with a background but no traffic should say "no clicks" rather than render an empty overlay and imply nobody was there.
Capture is off by default. Turn it on per client with PUT /client/v1/settings/heatmaps — it takes effect on the browser's next config fetch, with no change to the site's install snippet.
Start from the page list rather than guessing a URL: it is derived from the captured points, so every entry has data and nothing with data is missing. It also reports clicks and scroll_sessions separately, which is how you tell "nobody clicked here" from "this page's visitors haven't left yet".
The two coordinate systems
This is the part that decides whether a heatmap is trustworthy, so it is worth being explicit.
Horizontal is relative. x_rel is a fraction (0..1) of the viewport width the click was captured at. A click in the middle of a 1440px desktop and a click in the middle of a 360px phone are both 0.5 — the same cell, because in a responsive layout they hit the same thing.
Vertical is absolute. y_px is page pixels from the top of the document. It is deliberately not a percentage of page height: page length differs between visitors — lazy-loaded sections, cookie banners, a longer product list — so "60% down the page" is a different place for different people, and averaging it invents a hotspot nobody clicked.
Fixed elements are flagged, not fixed. A click on a sticky header or a floating chat button carries target_fixed: 1 and its y_px is viewport-relative. Draw those where the element sits, not at that page offset. Without the flag, one sticky nav becomes a vertical streak down the entire page — the classic broken heatmap.
Filtering
Every data endpoint takes the same session filters: device_type, utm_source, utm_medium, utm_campaign, country, and segment_id for a stored segment.
These resolve against the same session data the Sessions API reports, so the two always agree — including the referrer fallback, where an untagged but referred session reads as google.com / organic rather than blank. A heatmap filtered to google / organic and a sessions query filtered the same way select the same sessions, because neither re-derives the definition. Segment membership resolves through the identity graph, so a person-level segment catches rows captured under any identity that person used.
Build the controls from /heatmap/facets, not from a hardcoded list. It returns the values that exist for that page with the session count behind each:
{
"data": {
"page": "acme.com/pricing",
"device_type": [{ "value": "mobile", "sessions": 412 }, { "value": "desktop", "sessions": 96 }],
"utm_source": [{ "value": "facebook", "sessions": 310 }],
"utm_medium": [{ "value": "cpc", "sessions": 310 }]
}
}
Two rules that save a support ticket each:
- Scope facets to the page, not the account. Offering
desktopwhen no desktop visitor ever reached that page renders an empty heatmap, and empty-because-filtered is indistinguishable from broken. - Fetch facets unfiltered. If the facet call carries the active filters, picking one value empties the other dropdowns and there is no way back except a reload.
An unfiltered heatmap costs nothing extra: the session join is only evaluated when a filter is actually set.
Viewport windows
By default the click map mixes every screen size. That is usually what you want for a first look and wrong for a decision, because a 1200px layout and a 400px layout are different pages that happen to share a URL.
Pass viewport_min / viewport_max to read one breakpoint at a time:
# desktop only
curl "https://mythic-analytics.gulp.workers.dev/client/v1/data/heatmap?url=acme.com/pricing&viewport_min=1200" \
-H "Authorization: Bearer sk_live_..."
Narrower window → truer positions, less data. Wider → more data, blurrier positions. There is no setting that gives you both.
Scroll depth, precisely
The scroll curve is cumulative from the deepest band upward, so sessions_reached reads "this many sessions saw at least this far down".
Depth is max scroll y + viewport height — a visitor who never scrolled still saw one viewport. That makes the depth_px: 0 band equal to every measured session: it is the denominator, not a data point.
{
"data": {
"sessions": 1204,
"page_height": 5200,
"bands": [
{ "depth_px": 0, "sessions_reached": 1204, "reached_pct": 1.0 },
{ "depth_px": 900, "sessions_reached": 1204, "reached_pct": 1.0 },
{ "depth_px": 2000, "sessions_reached": 602, "reached_pct": 0.5 },
{ "depth_px": 3300, "sessions_reached": 121, "reached_pct": 0.1 }
]
}
}
Read that as: half the audience never got past 2000px, and the CTA at 3300px was seen by one visitor in ten.
One measurement is taken per session per page, when the visitor leaves it. A session whose final beacon never landed (browser killed the tab) is simply absent — this is a curve over sessions we measured, not over all pageviews. Compare sessions against your pageview count for that page to see the gap.
Drawing it
The background comes from session replay, not from framing your site. That means no X-Frame-Options or CSP negotiation, pages behind a login work, and — the one that matters most — the DOM is the page as it was when the clicks happened, not as it is today after a redesign.
Candidate recordings are the ones whose landing page matches the URL, so a page visitors only reach mid-funnel returns 404 — pass an explicit session_id from GET /client/v1/data/replays for those.
Pin rrweb-player to exactly 2.0.0-alpha.20. The "stable" 2.0.x/2.1.x ESM builds ship without the replay engine and crash on load (Cannot read properties of undefined (reading 'on')).
Four traps, all of which look like working code
Every one of these produces a page that renders — just wrong. They are worth reading before you write the renderer, because three of them fail silently.
| Trap | What you see | Why |
|---|---|---|
Sizing the stage from snapshot.height | Every click below the fold is missing | That height is one viewport, not the document. Use page_height from the scroll endpoint, or the deepest y_px you received |
| Overriding the player's transform once, on mount | Background aligned sometimes, shifted by half the page other times | rrweb-player re-applies its own centering (top/left: 50% + translate(-50%,-50%)) after mount, on its own schedule. A one-shot inline override is a race |
| Painting each scroll band as its own stripe | Thin colour bands separated by unpainted page | The scroll series is sparse — one row per band where a session's max landed. 4 sessions on a 17,000px page is 4 rows |
| Leaving the recorded cursor visible | A dot that reads as a data point | The player draws .replayer-mouse from the recording |
The background
Beat the player's layout with a stylesheet, not inline styles — an !important rule outlives every re-layout the player does, so there is no timing to get right:
/* scale and page size arrive as CSS variables, so a resize never re-applies rules */
.hm-stage .rr-player {
width: 100% !important; height: 100% !important;
padding: 0 !important; border: 0 !important; border-radius: 0 !important;
box-shadow: none !important; background: transparent !important;
overflow: hidden !important; position: relative !important;
}
.hm-stage .replayer-wrapper {
position: absolute !important; top: 0 !important; left: 0 !important; margin: 0 !important;
width: var(--hm-w) !important; height: var(--hm-h) !important; /* the DOCUMENT, not the viewport */
transform: scale(var(--hm-scale, 1)) !important;
transform-origin: top left !important; /* origin must match the canvas origin */
}
.hm-stage .replayer-wrapper iframe {
width: var(--hm-w) !important; height: var(--hm-h) !important; border: 0 !important;
}
.hm-stage .rr-controller,
.hm-stage .replayer-mouse,
.hm-stage .replayer-mouse-tail { display: none !important; }
import rrwebPlayer from 'rrweb-player'
import 'rrweb-player/dist/style.css'
const [snap, heat, scroll] = await Promise.all([
fetch(`${base}/client/v1/data/heatmap/snapshot?url=${page}`, { headers }).then((r) => r.json()),
fetch(`${base}/client/v1/data/heatmap?url=${page}`, { headers }).then((r) => r.json()),
fetch(`${base}/client/v1/data/heatmap/scroll?url=${page}`, { headers }).then((r) => r.json()),
])
const captureWidth = snap.data.width // the x denominator
// The document height. page_height only rides scroll points, so a clicks-only
// view has to fall back to the deepest cell it received.
const pageHeight = Math.max(
scroll.data.page_height || 0,
...heat.data.points.map((c) => c.y_px + 300),
snap.data.height || 0,
)
// Fit the width, then shrink further if the canvas would exceed the browser's
// maximum area — Safari caps it near 16M px and fails by rendering NOTHING.
let scale = Math.min(1, stage.clientWidth / captureWidth)
const area = captureWidth * pageHeight * scale * scale
if (area > 12e6) scale *= Math.sqrt(12e6 / area)
stage.classList.add('hm-stage')
stage.style.setProperty('--hm-w', `${captureWidth}px`)
stage.style.setProperty('--hm-h', `${pageHeight}px`)
stage.style.setProperty('--hm-scale', String(scale))
stage.style.width = `${captureWidth * scale}px`
stage.style.height = `${pageHeight * scale}px`
new rrwebPlayer({
target: host, // a child of stage, behind the canvas
props: {
events: snap.data.events, width: captureWidth, height: pageHeight,
autoPlay: false, showController: false, mouseTail: false, skipInactive: false,
},
}).pause()
The click map
Density has to accumulate — draw each cell as a radial alpha gradient in lighter composite mode, then colorize the accumulated alpha in one pass. Colouring each blob individually cannot show that two nearby cells overlap, which is the whole point of a heatmap.
const max = Math.max(...cells.map((c) => c.clicks))
ctx.globalCompositeOperation = 'lighter'
for (const cell of cells) {
const x = cell.x_rel * captureWidth * scale
const y = cell.y_px * scale // already page pixels — never scale by page height
// sqrt keeps one monster hotspot from flattening everything else to invisible
const weight = Math.sqrt(cell.clicks / max)
const g = ctx.createRadialGradient(x, y, 0, x, y, 24)
g.addColorStop(0, `rgba(0,0,0,${0.15 + 0.85 * weight})`)
g.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = g
ctx.beginPath(); ctx.arc(x, y, 24, 0, Math.PI * 2); ctx.fill()
}
ctx.globalCompositeOperation = 'source-over'
// then map accumulated alpha -> a sequential ramp (light->dark, one direction;
// a blue-through-red rainbow reads as four categories and its midpoints invert)
Cells with target_fixed: 1 belong wherever the sticky element sits, not at y_px on the page — draw them separately or skip them.
The scroll map
The series is sparse and cumulative, so each row fills from the previous row's depth down to its own, using its own reached_pct. Reach decreases monotonically, so a depth between two rows has the deeper row's value.
const sorted = [...scroll.data.bands].sort((a, b) => a.depth_px - b.depth_px)
let top = 0
for (const b of sorted) {
const bottom = Math.min(height, b.depth_px * scale)
if (bottom > top) {
ctx.fillStyle = rampColor(b.reached_pct) // 0.45 alpha keeps the page readable
ctx.fillRect(0, top, width, bottom - top)
}
top = Math.max(top, bottom)
}
// Below the deepest band, reach is genuinely ZERO. Leave it unpainted and label
// it — "nobody ever saw this" is the most useful thing a scrollmap says, and
// filling it with the last colour claims someone read to the bottom.
What this is not
- Not per-visitor. Both endpoints return aggregates. To watch one person, use session replay.
- Not mouse movement. Clicks and scroll depth only. Move maps are the highest-volume, lowest-signal type and are not captured.
- Not element-aware. These are pixels, not selectors. For "which button got clicked, by name", query
$autocaptureevents through the Data API — that surface knows element text andhref, and survives a layout change that moves the pixel. - Not retroactive. Points exist from the moment capture is enabled; there is no backfill of past traffic.
Base URL
https://mythic-analytics.gulp.workers.dev/client/v1/data/heatmap
Authentication
The same key system as the rest of /client/v1: an agency key (ak_, bind a location with ?location_id= or the X-Location-Id header) or a location secret key (sk_, auto-scoped to its own location). All five endpoints are reads and accept either; toggling capture is a Settings write and needs an agency key.