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.
Element-anchored clicks
Coordinates break on pages that change after they load. A 2-step order form that inserts its payment section after the first click moves everything below it down by that section's height, for exactly the visitors who used it. Their clicks on the reviews and footer land 1,500px below those elements on a background captured at load, and clicks on the payment fields land on nothing.
Since SDK 3.26, every heatmap click also records the element it hit: a short chain from the target up (tag, stable id, up to two classes, position among same-tag siblings, an identifier-style data-* attribute, a link's path, never element text) and where inside that element's box the click landed. GET /client/v1/data/heatmap?mode=element returns clicks grouped that way:
{
"data": {
"mode": "element",
"total_clicks": 5148,
"anchored_clicks": 812,
"anchored_share": 0.158,
"elements": [
{
"selector": "section.reviews:nth-of-type(2) > div.review-card:nth-of-type(2)",
"chain": [{ "t": "div", "n": 2, "k": ["review-card"] }, { "t": "section", "n": 2, "k": ["reviews"] }],
"clicks": 41,
"visitors": 30,
"offsets": [{ "ox": 0.52, "oy": 0.4, "clicks": 12 }]
}
]
}
}
To draw it, match each chain against the rebuilt background's DOM (target level first, every ancestor must agree on tag; an i or d identifies the element on its own, and classes, n and h score the rest), then place each offset at rect.left + ox * rect.width, rect.top + oy * rect.height. The element can sit anywhere in the background, so inserted content no longer moves the dot. A chain with no match is an element the background doesn't have: count it and don't draw it. Drawing it at its coordinates brings the original bug back. selector is descriptive for people and agents, not a guaranteed-unique selector.
Two things break matching silently. Measure boxes with the rebuilt page scrolled to the top, instantly: rrweb restores the visitor's scroll offset, and on a page with scroll-behavior: smooth both that restore and your scrollTo(0, 0) animate, so a fixed header measured mid-scroll lands hundreds of pixels low. And prefer a visible element among equally good matches: page builders clone elements (Elementor's sticky header leaves a hidden spacer with identical classes).
anchored_share says how much of the window carries elements. Points captured before SDK 3.26 have coordinates only and stay readable in the default mode=coordinate.
A load-time background still can't show content that only exists later. GET /heatmap/snapshot?state=final&min_page_height=<px> returns a whole recorded visit of the page whose page ended at least that tall. Replay it to its last event and the background shows the page as that visitor last saw it (the payment step open). Use page_heights (below) to pick the height.
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 }],
"viewport": [{ "value": "mobile", "sessions": 440, "min": 0, "max": 767 },
{ "value": "desktop", "sessions": 61, "min": 1280, "max": 20000 }]
}
}
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. Don't draw that mix on a page. A 1280px layout and a 390px layout are different pages that share a URL. x_rel only lines up across widths for fully fluid layouts, and y_px changes completely when a layout reflows. On a real paid landing page, 89% of clicks came from phones and 7% from desktops, spread over 104 distinct widths.
Read one layout bucket at a time. Take the buckets from /heatmap/facets (viewport, each with its min/max), default to the one with the most sessions, and pass the same range to the click, scroll and snapshot calls, so the background is captured at that layout too:
# desktop only
curl "https://mythic-analytics.gulp.workers.dev/client/v1/data/heatmap?url=acme.com/pricing&viewport_min=1280" \
-H "Authorization: Bearer sk_live_..."
Narrower window → truer positions, less data. Wider → more data, blurrier positions. There is no setting that gives you both. A bucket is the practical default. Inside the desktop bucket, a centred fixed-width layout still shifts horizontally between a 1280px and a 1920px screen, so narrow viewport_min/viewport_max around the background's width when you need pixel precision there.
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.
Candidates are recorded page loads of that URL, newest first, inside the viewport range you pass. Mid-funnel pages work without a session_id. A snapshot from another breakpoint is never substituted. With no match you get 404, and you should draw on a blank stage at the bucket's typical width rather than a background from another layout.
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')).
Six 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 |
| Trusting a load-time background on a page that grows | Clicks in blank space below the footer, and clicks lower down drawn on the wrong content | The background is the page as it loaded. A 2-step order form or an accordion inserts content later, and everything below the insertion shifts for the visitors who triggered it. page_heights on the click and scroll responses gives each session's end-of-visit page height. Measure where the rebuilt background's content ends, and warn when a real share of sessions ended well past it |
Stretching the iframe without freezing vh | A full-screen hero thousands of px tall, everything below it shifted down | Once the iframe is document-tall, 100vh in the page's CSS means the document height. After the snapshot rebuilds (fullsnapshot-rebuilded), rewrite vh/vmin/vmax in the iframe's readable stylesheets to px at snapshot.height |
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'
// One layout at a time: the busiest bucket from /heatmap/facets.
const facets = await fetch(`${base}/client/v1/data/heatmap/facets?url=${page}`, { headers }).then((r) => r.json())
const bucket = [...facets.data.viewport].sort((a, b) => b.sessions - a.sessions)[0]
const vw = `viewport_min=${bucket.min}&viewport_max=${bucket.max}`
const [snap, heat, scroll] = await Promise.all([
fetch(`${base}/client/v1/data/heatmap/snapshot?url=${page}&${vw}`, { headers }).then((r) => r.json()),
fetch(`${base}/client/v1/data/heatmap?url=${page}&${vw}`, { headers }).then((r) => r.json()),
fetch(`${base}/client/v1/data/heatmap/scroll?url=${page}&${vw}`, { 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 text-aware.
mode=elementknows an element's structure, not its words. For "which button got clicked, by label", query$autocaptureevents through the Data API, which records element text. - 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_, which
must bind a location on every request with ?location_id= or the
X-Location-Id header) or a location secret key (sk_, auto-scoped to its
own location). An agency key that binds no location returns
400 location_required; one that names a location it is not linked to returns
403 location_not_linked.
All five endpoints are reads and accept either key; toggling capture is a Settings write and needs an agency key.