Open a Matomo heatmap with a Cal.com or Calendly embed on the page and you'll often find the widget squeezed into a couple hundred pixels with its own scrollbar, the click markers stacked at the top of the frame instead of spread down the form. The clicks Matomo recorded on the parent page are usually fine. What's wrong is the iframe in the snapshot underneath them: it never grew to its content height, so any read on form drop-off or calendar selection ends up piled into a tiny rectangle. (Clicks made inside a cross-origin embed are a separate problem, which I'll get to.)
The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It forces every iframe to a sensible height during capture (real scrollHeight for same-origin embeds, at least 800px for cross-origin ones) and restores the original style afterward. The rest of this post is how to do the same thing by hand, plus the permanent fixes that survive every future capture.
Iframes show up collapsed because a cross-origin embed like Cal.com or Calendly negotiates its real height over postMessage at runtime, often after Matomo has already frozen the DOM snapshot. If you control both sides, wire up a postMessage height handshake. For a vendor embed, swap the bare <iframe src> for the official embed script, or force a minimum height (we use 800px for booking widgets) before recapturing. Two things to know: a stored snapshot won't change until you delete it or trigger a fresh capture with ?pk_hsr_forcesample=1, and clicks made inside a cross-origin frame may not be recorded at all, so resizing makes the screenshot truthful without filling in the missing click data.
How to fix it without the extension
There's a console snippet that papers over the problem right before a capture, and a few permanent fixes you can ship with the embed. Pick whichever fits the iframe you're dealing with. But first, the part that trips most people up: the snapshot.
Matomo grabs the DOM snapshot in the visitor's browser, normally once the page finishes loading. A console tweak you run now only affects a new snapshot, not the one already stored against your heatmap. So before any of the snippets below will show up, you need a fresh capture:
- Force a new sample. Append
?pk_hsr_forcesample=1to the page URL and load it. Matomo treats that visit as a fresh snapshot candidate. - Or use manual capture (plugin 5.1.0+). Edit the heatmap and turn on "Capture Heatmap Snapshot Manually". Load the page, run your resize snippet in the console, then run
_paq.push(['HeatmapSessionRecording::captureInitialDom', { idHeatmap }])with your heatmap's id. Enabling this option deletes the existing snapshot, and your IP can't be in Matomo's excluded-IPs list or the capture won't fire.
Diagnose what you're looking at
Before you change anything, find out whether the iframe is same-origin (you can read its content) or cross-origin (you can't). Open the page in Chrome, hit F12, click the iframe in the Elements panel so it becomes $0, then switch to the Console:
// Select the iframe in the Elements panel first ($0), then paste this.
console.log('Frame height:', $0.getBoundingClientRect().height);
try {
const doc = $0.contentDocument;
if (!doc) {
console.log('Content: cross-origin (the parent can\'t read it)');
} else if (!doc.body) {
console.log('Content: same-origin, but not loaded yet. Try again');
} else {
console.log('Content height:', Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight));
}
} catch (e) {
console.log('Content: cross-origin (access blocked)');
}If the frame height and content height disagree, that gap is the difference between what the visitor saw and what Matomo will capture. If the second line says cross-origin, the parent page can't read the embed and most of the snippets below will skip it, so jump to the minimum-height approach instead.
Resize same-origin iframes from the console
If your iframe is genuinely same-origin (same scheme, host, and port as the page: a self-hosted form, an internal tool on your own domain), the parent can read its content height directly. Paste this just before triggering the capture:
// Paste in the console. Resizes every same-origin iframe to its content height.
document.querySelectorAll('iframe').forEach(f => {
try {
const doc = f.contentDocument;
if (doc && doc.body) {
f.style.height = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight) + 'px';
}
} catch (e) { /* cross-origin, skip */ }
});That's it for same-origin. The browser blocks contentDocument access on cross-origin iframes, so any vendor widget gets silently skipped. Note that a different subdomain still counts as cross-origin here; for those, use the postMessage handshake below rather than the deprecated document.domain trick. Run the diagnostic again to confirm the heights look right, then capture.
Force a minimum height on cross-origin iframes
For Cal.com, Calendly, YouTube, Stripe Checkout, and any other vendor embed, the parent page can't read the iframe's content. You can still set a tall enough height from the outside, which is the best you can do without the embedded site cooperating:
// Edit MIN_HEIGHT to fit your tallest embed (e.g. 800 for Cal.com, 1200 for long forms).
const MIN_HEIGHT = 800;
document.querySelectorAll('iframe').forEach(f => {
if (f.getBoundingClientRect().height < MIN_HEIGHT) {
f.style.height = MIN_HEIGHT + 'px';
}
});This is a guess, not a measurement, so set it generously. An iframe that's a little too tall renders blank space below the content, which is harmless. One that's too short still chops off the bottom of the form. We default to 800 for booking widgets and 1200 for long signup forms, and bump it per page when a specific embed needs more.
One caveat worth stating plainly: forcing the height only fixes the background of the screenshot. Matomo's own docs note that clicks and scrolls inside a cross-origin iframe may not be captured at all. So resizing the frame can hand you an honest-looking but still empty click layer over a Calendly or Stripe embed. The fix makes the screenshot truthful; it doesn't conjure interaction data the tracker never recorded.
Permanent fix when you control both sides: postMessage
If the iframe lives on a domain you control (your own subdomain, a sister product, an internal tool), the cleanest answer is a small postMessage handshake. The child page reports its height whenever the content reflows, and the parent applies it. No more guessing.
Send to a known origin from the child, and don't trust anything you can't verify on the parent. Both ends matter for security: a wildcard '*' target or an unvalidated listener is how you leak data to, or take instructions from, the wrong frame.
In the embedded child page:
// In the child page. Report height only to the known parent origin.
const PARENT_ORIGIN = 'https://www.example.com';
const sendHeight = () => parent.postMessage(
{ type: 'iframe-height', height: document.body.scrollHeight },
PARENT_ORIGIN
);
new ResizeObserver(sendHeight).observe(document.body);In the parent page:
// In the parent page. Trust only the known child origin, and sanity-check the value.
const CHILD_ORIGIN = 'https://embed.example.com';
window.addEventListener('message', e => {
if (e.origin !== CHILD_ORIGIN) return;
if (e.data?.type !== 'iframe-height') return;
const h = Number(e.data.height);
if (!Number.isFinite(h)) return;
const height = Math.min(Math.max(h, 0), 5000); // clamp out absurd values
document.querySelectorAll('iframe').forEach(f => {
if (f.contentWindow === e.source) f.style.height = height + 'px';
});
});This survives lazy-loaded content, font swaps, and dynamic form steps, and it gives your real visitors a smoother experience because the iframe stops needing its own scrollbar.
Use the vendor's official embed snippet
Several vendors ship a resize protocol in their official embed code. If you copy-pasted a bare <iframe src="..."> from a docs page or a support thread, swap it for the drop-in script. The common pattern that breaks here is a bare iframe pointing at the rendered URL, and it's almost always a copy-paste shortcut rather than a deliberate choice.
| Embed | What breaks | Use instead | Sizing note |
|---|---|---|---|
| Cal.com | bare <iframe src> | the embed builder's <script> or @calcom/embed-snippet | the embed sends internal resize messages to the parent automatically |
| Calendly | direct iframe (no auto-resize, no event tracking) | the inline embed snippet with data-resize="true" (or resize: true) | auto-resize is opt-in; direct iframes don't support it |
| HubSpot | hand-rolled iframe | the hosted JS form embed | confirm resize behaviour for your form type before relying on it |
| YouTube / Vimeo | fixed 16:9 frame, squashed at capture | click-to-load poster (lite-youtube-embed) | only the poster needs to size; the player loads on click |
| Stripe & other payment iframes | locked to a small default frame | nothing; the vendor sets its own height | force a min height for the screenshot only |
iframe-resizer for arbitrary cross-origin embeds you control
If you control both sides but don't want to write the postMessage protocol yourself, the iframe-resizer npm package handles the edge cases (lazy content, late-loading fonts, iOS quirks). Two scripts, one in the parent and one in the child, same effect as the snippet above with more battle-testing across browsers.
Replace the iframe where you can
For YouTube and Vimeo on heatmap-tracked pages, swap the iframe for a poster image and only insert the player on click. The visitor still gets the video, and the heatmap captures a clean, full-size click target instead of a tiny placeholder. lite-youtube-embed is the canonical version of this, and it's faster for real users too, since the heavy player only loads when someone actually wants it.
This doesn't help for booking widgets or payment iframes, where the embed is the interaction. It helps for video, social timelines, and anything else where the iframe is decorative until clicked.
Why the iframe stays collapsed
Matomo's heatmap doesn't take a photo of your page. Its tracker captures the DOM, the HTML structure, in the visitor's browser, usually once the page has loaded. That snapshot gets stored. Later, when you open the heatmap, Matomo rebuilds the page from the stored HTML and pulls the CSS and images straight from your live site to paint it.
The catch is timing. The snapshot freezes the page as it was at capture time. A cross-origin embed negotiates its real height over postMessage at runtime, and those messages can land after Matomo has already taken the snapshot. So the stored DOM holds the iframe at whatever height it had on load, often the default, and that's the frame you see in the heatmap even though the live page resized it a moment later. Same-origin iframes can be measured by the parent, but most teams only set a CSS height for live UX rather than for the snapshot, so they collapse the same way.
- 1
Visitor loads the page
iframe resizes after the snapshotThe negotiated height never reaches the stored DOM - 2
Tracker snapshots the DOM in the browser
- 3
HTML stored in Matomo
- 4
You open the heatmap
- 5
Page rebuilt from stored HTML + live CSS and images
What's actually failing
A few patterns we keep running into:
- Bare
<iframe src="https://cal.com/...">tags pasted into a CMS instead of Cal.com's official embed script. The official script wires up postMessage; the bare iframe doesn't. - Calendly inline embeds with no height set anywhere in CSS. Calendly's own snippet handles it; a hand-rolled iframe doesn't.
- HubSpot and Marketo forms that load asynchronously after the page is interactive. The form might be 600px tall in production and 0px tall at capture time, because the form's JS hadn't injected the fields yet when Matomo snapshotted the DOM.
- YouTube and Vimeo iframes at their default 16:9 ratio, rendered into a column at a viewport they weren't sized for and ending up squashed.
- Stripe Checkout and other payment iframes that intentionally lock to a small default frame and refuse to expand for security reasons. The vendor decides the height; you don't.
- Iframes with no explicit height and no resize handshake. An iframe doesn't grow to fit its document on its own. Without a set height it falls back to the default frame size (150px in the HTML spec), no matter how tall the embedded content really is.
If your iframe is collapsed in the heatmap, you're hitting at least one of these. Form embeds usually hit two.
This is the same family of problem that breaks fonts, images, scroll containers, and sticky headers in the same screenshot. We've written a longer post on broken Matomo heatmap screenshots that walks through the rest, plus separate posts on why fonts aren't loading and why images aren't loading since those come up almost as often.
What we'd actually do
If the iframe lives on your own domain, ship the postMessage handshake. It fixes the heatmap and smooths out the live embed at the same time.
If it's a vendor embed, use the vendor's official snippet. Cal.com and Calendly both ship a working resize path; most of the breakage we see is someone pasting the rendered iframe URL instead of the embed script.
If neither is reachable (third-party chat, payment iframes, vendor booking widgets you can't change), the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't touch the stack. It forces a sane height during capture (real scrollHeight for same-origin, at least 800px for cross-origin) and restores the original style after, so the live page is unaffected. Free, open source, code on GitHub.
Martez is the larger project the extension came out of. It connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. Join the waitlist if that's relevant to you.
Most of the time this is a vendor snippet swap or a postMessage handshake away. Worth wiring up once.