Why iframes show up collapsed in your Matomo heatmap (and how to fix it)

Cal.com, Calendly, YouTube, and HubSpot embeds capture as tiny default-height frames in Matomo heatmaps, often with an internal scrollbar. The clicks pile up on empty space and any insight into what visitors did inside the embed is lost. Here's why it happens and how we deal with it.

If you've opened a Matomo heatmap and found your Cal.com widget rendered as a 200-pixel sliver with an internal scrollbar, your YouTube embed shrunk to a placeholder, and the click markers piled up at the top of the frame instead of spread across the form fields below, the click data is technically there. Matomo recorded the coordinates correctly. What's broken is the iframe in the screenshot underneath them. The embed never expanded to its content height when the Matomo server re-rendered the page, so any insight into form drop-off, calendar selection, or CTA clicks inside the iframe ends up stacked on top of itself in a tiny rectangle.

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 after. The rest of this post is how to do the same thing without an extension, plus the permanent fixes that survive every future capture.

How to fix it without the extension

There's a console snippet that papers over the problem right before each capture, and a handful of permanent fixes you can ship with the embed. Pick whichever fits the constraints of the iframe you're dealing with.

Diagnose what you're looking at

Before you change anything, check whether the iframe is same-origin (you can read its content) or cross-origin (you can't). Open the page in Chrome, hit F12 to open DevTools, click the iframe in the Elements panel so it becomes $0, then switch to Console:

js
// Paste in the console after selecting the iframe in the Elements panel
console.log('Visible:', $0.getBoundingClientRect().height);
try { console.log('Content:', $0.contentDocument.body.scrollHeight); }
catch (e) { console.log('Content: cross-origin, can\'t read'); }

If the two numbers disagree, that's the gap 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's contents and most of the snippets below will skip it. Force a minimum height on those instead.

Resize same-origin iframes from the console

If your iframe is on the same origin as the page (a self-hosted form, an internal app embedded into your marketing site, a sister product on a subdomain that shares your origin via document.domain), the parent can read its content height directly. Paste this just before triggering the heatmap capture:

js
// 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) f.style.height = doc.body.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. Run the diagnostic snippet again to confirm the heights look right, then trigger the 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:

js
// 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. Set it generously. An iframe that's a little too tall renders blank space below the content, which is harmless. An iframe 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 adjust per page if a specific embed needs more.

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 every time the content reflows, the parent listens and applies. No more guessing.

In the embedded child page:

js
// Add to the iframe's child page
const sendHeight = () => parent.postMessage(
  { type: 'iframe-height', height: document.body.scrollHeight },
  '*'
);
new ResizeObserver(sendHeight).observe(document.body);

In the parent page:

js
// Add to the parent page
window.addEventListener('message', e => {
  if (e.data?.type !== 'iframe-height') return;
  document.querySelectorAll('iframe').forEach(f => {
    if (f.contentWindow === e.source) f.style.height = e.data.height + 'px';
  });
});

This survives lazy-loaded content, font swaps, and dynamic form steps. It also gives your real users a smoother experience, because the iframe stops needing its own scrollbar.

Use the vendor's official embed snippet

Cal.com, Calendly, and HubSpot all ship a built-in postMessage resize protocol in their official embed snippets. If you copy-pasted a bare <iframe src="..."> from a docs page or a support thread, swap it for the vendor's drop-in script. They've already solved this for you.

For Cal.com that's the @calcom/embed-snippet package or the <script> tag from their embed builder. For Calendly it's the inline embed snippet on calendly.com. For HubSpot it's the form embed code from the form's "Share" tab. Bare iframes pointing at the rendered URL are the common pattern that breaks here, and it's almost always a copy-paste mistake rather than a deliberate choice.

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, 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 iframe 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 pattern, and it's faster for real users too because the heavy YouTube 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 does help for video, social timelines, and anything else where the iframe is decorative until clicked.

Why Matomo can't size your iframes

Same root cause as the font and image versions of this problem, with one extra wrinkle. Matomo's screenshot capture is a two-step process. First, the tracker serializes your live DOM into HTML and ships it off. Then, your Matomo server re-renders that HTML to produce the screenshot you see in the heatmap view. No browser session, no cookies, no Referer pointing at your domain. Just an HTML file being rendered cold from a different IP.

For iframes, that re-render captures whatever the embed shows at its default state. An iframe is a separate document. The browser blocks the parent from auto-sizing a cross-origin iframe, so the height you see in production is whatever the vendor's embed code negotiated via postMessage at runtime, plus any CSS height your team set for live UX. The Matomo renderer doesn't run JavaScript long enough for those handshakes to land. Same-origin iframes can technically be measured, but most teams rely on a CSS height set for live UX rather than for a static screenshot, so the renderer ends up with a default-collapsed frame either way.

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 where the height isn't set anywhere in CSS. Calendly's own snippet sets 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 serialized the DOM.
  • YouTube and Vimeo iframes at their default 16:9 ratio for a 560-pixel-wide column rendered by Matomo at a different viewport, 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 embedded vendor decides the height. You don't.
  • Same-origin iframes with height: auto set in CSS, which the browser computes correctly at runtime but which the static-render server treats as 0 because it doesn't run the layout pass long enough.

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, smooths out the live embed, and you stop fighting layout shifts forever.

If it's a vendor embed, use the vendor's official snippet. Cal.com, Calendly, and HubSpot ship a working resize protocol in theirs. Most of the breakage we see is people 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 embed for), the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't change 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.