# 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.

Published: 2026-05-06
Categories: Matomo Heatmap Helper
Canonical: https://martez.io/blog/matomo-heatmap-cut-off

---

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 spots. What's broken is the screenshot underneath them. Some container on your live page capped its own height and let its content overflow inside a scrolling region (the sidebar, the tab panel, the slide-over with the FAQ), and it never got 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.

<Callout type="tip" title="TL;DR">

Your heatmap goes blank below the cutoff because some container on the page caps its height and hides its overflow (a sidebar, a tab panel, a drawer), so Matomo's screenshot clips it at that boundary. The clicks are still recorded; only the picture is wrong. Fix it by scoping a stylesheet under `html.matomoHeatmap` that sets `max-height: none` and `overflow: visible` on those containers, then regenerate the snapshot. That last step is the one people miss: Matomo saves the DOM on page load, so it won't see your change until you recapture.

</Callout>

That extension is a free Chrome add-on we maintain, [Matomo Heatmap Helper](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd). Right before each capture it walks the page, finds the elements whose `scrollHeight` exceeds their `clientHeight`, and rewrites their `height` and `max-height` so the screenshot sees the full content, then restores the originals when capture is done. The rest of this post is how to do the same thing by hand, plus a permanent CSS pattern worth shipping.

## How to fix it without the extension

There's a console snippet that papers over the problem before a capture, and a CSS pattern you can ship that keeps 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 for DevTools, switch to the Console, paste this, hit Enter. The snippet only flags elements that are actually scroll containers (their computed `overflow-y` is `auto`, `scroll`, `hidden`, or `clip`) and whose content is taller than the box by more than a few pixels:

```js
// Paste into the browser console on the affected page.
// Lists scroll containers whose content is clipped by a capped height.
const skip = new Set(['HTML', 'BODY', 'SCRIPT', 'STYLE', 'TEXTAREA']);
Array.from(document.querySelectorAll('*'))
  .filter(el => !skip.has(el.tagName))
  .filter(el => {
    const oy = getComputedStyle(el).overflowY;
    return ['auto', 'scroll', 'hidden', 'clip'].includes(oy)
      && el.scrollHeight - el.clientHeight > 5;
  })
  .forEach(el => console.log(el.scrollHeight - el.clientHeight, el));
```

Filtering on computed `overflow-y` matters: `scrollHeight > clientHeight` on its own also matches `<html>`, normal page scroll, code panes, and anything with visible overflow, none of which are your problem. What you're after is a container reporting hundreds or thousands of pixels of clipped content. That's where the heatmap goes dark.

### The quick fix: force-expand every clipped container

This is the snippet we paste into the console once the page has fully rendered. It expands the scroll containers whose content overflows and turns their clipping off, so by the time Matomo serializes the DOM, those sections are unrolled:

```js
// Paste into the console after the page has fully rendered.
// Expands every scroll container so its full content lands in the captured DOM.
const skip = new Set(['HTML', 'BODY', 'SCRIPT', 'STYLE', 'TEXTAREA']);
document.querySelectorAll('*').forEach(el => {
  if (skip.has(el.tagName)) return;
  const oy = getComputedStyle(el).overflowY;
  if (['auto', 'scroll', 'hidden', 'clip'].includes(oy)
      && el.scrollHeight - el.clientHeight > 5) {
    el.style.height = el.scrollHeight + 'px';
    el.style.minHeight = el.scrollHeight + 'px';
    el.style.maxHeight = 'none';
    el.style.overflow = 'visible';
  }
});
```

To see what's left before you capture, re-run the count:

```js
// How many scroll containers are still clipping. A few may remain on purpose.
const skip = new Set(['HTML', 'BODY', 'SCRIPT', 'STYLE', 'TEXTAREA']);
Array.from(document.querySelectorAll('*'))
  .filter(el => !skip.has(el.tagName))
  .filter(el => {
    const oy = getComputedStyle(el).overflowY;
    return ['auto', 'scroll', 'hidden', 'clip'].includes(oy)
      && el.scrollHeight - el.clientHeight > 5;
  }).length;
```

The big offenders should be gone. A non-zero count isn't a failure on its own. What's left is usually a control that's meant to scroll, or something the snippet can't reach: closed shadow roots and cross-origin iframes are both out of bounds for it. Inspect any large remaining deltas before you decide they matter.

Here's the part the snippet alone doesn't cover, and the step most people miss.

<Callout type="info" title="Regenerating the Matomo snapshot" collapsable collapsableOpen>

By default Matomo captures the initial DOM automatically when the page loads. So a snippet you paste afterward changes the live page but not the screenshot Matomo already saved. To get your expanded layout into the snapshot, you have to capture it manually.

In Matomo 5.1+ (the Heatmap & Session Recording plugin), edit the heatmap and turn on **Capture Heatmap Snapshot Manually**, then save. That deletes the existing snapshot, so the next capture is the one that counts. Then:

1. Open the affected URL and wait until the clipped content actually exists in the DOM.
2. Run the diagnostic and the expand snippet from above.
3. Trigger the capture from the console:

```js
// Replace 7 with your heatmap's ID.
_paq.push(['HeatmapSessionRecording::captureInitialDom', { idHeatmap: 7 }]);
```

One gotcha: if your IP is on Matomo's excluded-IPs list (global or per-site), the manual capture won't fire. Take yourself off the list while you test.

</Callout>

### The permanent fix: a heatmap-only stylesheet

Matomo adds the class `matomoHeatmap` to the `<html>` element while it renders the screenshot. Scope your overrides under it and they apply only during capture, never for real visitors. That's the reliable hook, and it's the one Matomo documents for exactly this:

```css
/* Edit the selectors to match your site's scroll containers. */
html.matomoHeatmap .sidebar,
html.matomoHeatmap .tab-panel,
html.matomoHeatmap [data-scrolls] {
  max-height: none !important;
  height: auto !important;
  min-height: 0 !important;
  overflow: visible !important;
}
```

Because the rules sit behind `html.matomoHeatmap`, your sticky sidebars and scrollable tab panels behave normally for everyone else. Use `html.matomoHsr` instead if you want the same override to cover session recordings, and note that `html.piwikHeatmap` is the legacy alias if you're on an old install.

If you want to preview the capture layout in a normal browser, add the same class yourself behind a query param:

```js
// Add to your site to preview capture mode at ?heatmap=1
if (new URLSearchParams(location.search).get('heatmap') === '1') {
  document.documentElement.classList.add('matomoHeatmap');
}
```

A note on what doesn't work: gating the override on a server-side User-Agent check. The DOM snapshot is built in the visitor's browser by the tracker and sent to Matomo. Matomo isn't crawling your origin to fetch the HTML, so a User-Agent rule at your server never changes what's in the snapshot. The `html.matomoHeatmap` class is the thing that's actually present during capture, so that's what you scope on.

## 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 interaction data mostly survives this. Matomo records each click as a position relative to the element it landed on, not as a fixed pixel on the document, so the events are usually still there even when the picture under them is wrong. What's gone is the visual context. (Matomo does call out sticky headers, dropdowns, and cross-origin iframes as cases where even the overlay can end up in the wrong place, so don't treat the data as bulletproof on those.)

## 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, and the screenshot only sees the viewport-height slice.
- Tabbed panels where each `[role=tabpanel]` has its own scroll container. This isn't a universal default, but it's common once a product team adds `max-height` plus `overflow-y: auto` to keep tabs from pushing the page around. The active tab gets clipped at its capped height and the inactive ones 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 stays capped during capture.
- Dashboard cards with inner scrollers, where 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-library scroll regions: shadcn/Radix `ScrollArea`, MUI `Drawer` with internal scroll, Headless UI tab panels. Worth knowing when you're auditing a page, because 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 at once.

This is 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](/blog/fix-broken-matomo-heatmap-screenshots) that walks through the rest, plus separate posts on [why fonts aren't loading in your Matomo heatmap](/blog/matomo-heatmap-fonts-not-loading) and [why images aren't loading in your Matomo heatmap](/blog/matomo-heatmap-images-not-loading).

## What we'd actually do

If you control the templates, ship the `html.matomoHeatmap` stylesheet and regenerate the snapshot. It's a handful of selectors scoped under one class, and it stays in production for as long as you're using Matomo heatmaps. Five minutes of work that you don't have to think about again.

If you can't edit the CSS, the console snippet plus a manual capture covers the same ground for one-off screenshots. The catch is you have to remember to run it each time.

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](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd) 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](https://github.com/martez-io/matomo-heatmap-helper).

[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-heatmap-cut-off) 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](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-heatmap-cut-off) if that's relevant to you.

One scoped class and a recapture. Worth doing once.
