Why your Matomo heatmap cuts off past a certain point (and how to fix it)

Matomo heatmap screenshots go black or blank below a certain line when a sidebar, tabbed panel, or any container with a capped height clips its overflow during capture. The clicks are still recorded, just landing on empty space. Here's why it happens and how we deal with it.

If you've opened a Matomo heatmap and found everything below a certain line replaced with a black rectangle, your sidebar collapsed at the height it had on screen, and click markers stacked uselessly on top of flat color, the clicks themselves are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. Some container on your live page that capped its own height and let its content overflow inside a scrolling region (the sidebar, the tab panel, the slide-over with the FAQ) didn't get its full content into the captured DOM. The container kept its capped height during the screenshot pass, and Matomo painted the area beyond it as empty space.

The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. Right before each capture, it walks every element on the page, finds the ones whose scrollHeight exceeds their clientHeight, and rewrites their height and max-height so the screenshot sees the full content. It restores the originals when capture is done. The rest of this post is how to do the same thing without an extension, plus a permanent CSS pattern worth shipping.

How to fix it without the extension

There's a console snippet that papers over the problem right before each capture, and a query-param-gated CSS pattern you can ship to keep your real users' UX intact. Pick whichever fits your stack.

List the offending elements

Before you change anything, find the containers that are clipping. Open the page in Chrome, hit F12 to open DevTools, switch to the Console, paste this, hit Enter. Anything logged with a difference bigger than five pixels or so is a candidate:

js
// Paste into the browser console on the affected page.
// Lists every element whose contents are clipped by a capped height.
Array.from(document.querySelectorAll('*'))
  .filter(el => el.scrollHeight > el.clientHeight + 1)
  .forEach(el => console.log(el.scrollHeight - el.clientHeight, el));

Tiny differences (one or two pixels) are usually subpixel rounding and not your problem. Anything reporting hundreds or thousands of pixels of clipped content is what you're after. That's where the heatmap is going dark.

The quick fix: force-expand every clipped container

This is the snippet we paste into the browser console right before triggering Matomo's heatmap capture. It walks every element on the page, expands the ones whose contents overflow, and turns their overflow rules off so the full content is visible. By the time Matomo serializes the DOM, the clipped sections are unrolled.

js
// Paste into the console. Expands every container that has hidden overflow.
document.querySelectorAll('*').forEach(el => {
  if (el.scrollHeight > el.clientHeight + 1) {
    el.style.height = el.scrollHeight + 'px';
    el.style.minHeight = el.scrollHeight + 'px';
    el.style.maxHeight = 'none';
    el.style.overflow = 'visible';
  }
});

To confirm nothing's still clipped before you trigger the capture:

js
// Returns the number of elements still clipping their contents. Should be zero.
Array.from(document.querySelectorAll('*'))
  .filter(el => el.scrollHeight > el.clientHeight + 1).length;

If that's zero, you're good. If it's not, the remaining elements are usually inside shadow roots or cross-origin iframes the snippet can't reach. More on that below.

That's the quick path. It works for one-off captures and the review cycles where you need a clean screenshot now and don't want to wait on a code change. For pages you'll re-capture regularly, ship the permanent fix below.

The permanent fix: a heatmap-only stylesheet

The trick is to keep your real users' UX intact (sticky sidebars, scrollable tab panels, all the stuff that exists for a reason) while letting the heatmap pass see the full content. A query-param-gated CSS class is the simplest way to do that:

css
/* Edit the selectors below to match your site's containers */
html.heatmap-capture .sidebar,
html.heatmap-capture .tab-panel,
html.heatmap-capture [data-scrolls] {
  max-height: none !important;
  height: auto !important;
  min-height: 0 !important;
  overflow: visible !important;
}
js
// Add to your site. Turns the override on when you visit with ?heatmap=1
if (new URLSearchParams(location.search).get('heatmap') === '1') {
  document.documentElement.classList.add('heatmap-capture');
}

Now visiting https://your-site.com/page?heatmap=1 puts the page in capture-friendly mode. Real users hit the page without the param and keep their normal scroll UX. Same site, two layouts, one query param between them.

If you don't want to expose the trigger in the URL, swap the param check for a cookie set from an admin-only route, or for a User-Agent check against Matomo's screenshot fetcher (grep your access logs for the exact UA string Matomo's renderer uses, then gate on it server-side). Same idea, different gate.

The trade-off

Don't ship max-height: none to every visitor. Sticky sidebars, scrollable tab panels, and capped-height drawers exist for a reason: they keep navigation in view, they let panels coexist with the rest of the layout, they prevent a 4,000-pixel-tall accordion from taking over the page. The whole point of gating the override is to keep production the way real users expect it and only flip the layout when you're capturing.

Tested workflow: open the page in an incognito window with ?heatmap=1, click around to record the heatmap session, leave the tab open until Matomo's capture fires. Real users on the same page in their normal sessions never see the override.

Why Matomo can't capture past the cutoff

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. The renderer paints the page exactly as the serialized DOM describes it. If a container in that DOM has height: 600px and overflow: hidden with content that's actually 2,000 pixels tall, only the top 600 pixels make it into the picture. Everything below gets clipped at the container boundary, and the heatmap canvas paints the area beyond it as empty space (black, white, or transparent depending on what's underneath).

The click coordinates are tracked at the document level, so they survive the clipping just fine. The pixels they were pointing at don't.

What's actually failing

A few patterns we keep running into:

  • Sidebars with max-height: calc(100vh - header) and overflow-y: auto, common for mega-nav, faceted filters, and chat panels. The sidebar is taller than the viewport on the live page. The screenshot only sees the viewport-height slice.
  • Tabbed panels where each [role=tabpanel] has its own scroll container, default behavior in Bootstrap, Tailwind UI, MUI, and most component libraries. The active tab gets clipped at its capped height and the inactive tabs aren't visible at all.
  • Drawer-style sections that aren't really modals: slide-overs, expanded filter trays, inline cart panels. Anything capped at 100vh or a fixed pixel height on purpose. The container stays capped during capture.
  • Dashboard cards with inner scrollers, fixed-height tiles where the content overflows inside an overflow-y: auto div. Matomo screenshots the tile at its outer dimensions and everything above the visible slice of the inner scroller is gone.
  • Component libraries that bake this in: ScrollArea from shadcn/ui, MUI's Drawer with internal scroll, Tailwind UI's combobox lists, Headless UI tab panels. Worth knowing about when you're auditing the page, the offending element is often three layers deep inside a wrapper you didn't write.
  • Iframes with capped dimensions. The iframe's own DOM is clipped at the outer iframe size, and Matomo can't reach inside cross-origin iframes anyway.

If your heatmap goes dark past a certain line, you're hitting at least one of these. Most pages with a content sidebar hit two of them at once.

This is also the same family of problem that breaks fonts, images, 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 in your Matomo heatmap and why images aren't loading in your Matomo heatmap.

What we'd actually do

If you control the templates, ship the gated stylesheet. It's a handful of selectors and a query-param check, and it stays in production for as long as you're using Matomo heatmaps. Five minutes of work, lasts forever.

If you can't, the console snippet covers the same ground for one-off captures. The catch is you have to remember to paste it before each capture.

If neither is reachable (CMS-locked templates, third-party widgets you don't own, anything where you can't add a CSS rule or a script tag), the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't change the stack. It runs the same scrollHeight/maxHeight rewrite during capture and restores the originals after, plus equivalent fixes for fonts, images, and sticky headers. 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.

A few selectors and a query param. Worth doing once.