Why your sticky header repeats (or freezes) in your Matomo heatmap (and how to fix it)

Fixed and sticky headers either repeat down the page or freeze across the middle of Matomo heatmap screenshots, because Matomo renders the captured DOM as one tall image with no consistent viewport for them to anchor to. Here's why it happens and how we deal with it.

If you've opened a Matomo heatmap and found your header stamped down the page two or three times like a postage mark, or one copy of it frozen across the middle covering the content underneath, the tracking itself is usually fine. Matomo still recorded the clicks. What broke is the screenshot Matomo drew underneath them: your sticky header didn't render the way it does in a live browser, so it either repeats at several scroll positions or pins itself wherever the renderer happened to land. Either way, the click markers end up sitting on top of nav links, and a chunk of your real heatmap turns into a dead zone.

TL;DR

Your tracking is fine. The problem is that Matomo re-renders the stored DOM as one static image with no scrolling viewport, so fixed and sticky headers have nothing to anchor to and either repeat down the page or freeze across the middle. Drop them into normal flow during the capture with a few lines of CSS scoped to Matomo's own html.matomoHeatmap hook (position: static !important), which only applies while Matomo renders the snapshot, then regenerate the heatmap so it picks up the change. If you can't edit the site's CSS, switch the heatmap to manual capture and paste a one-line unstick snippet, or let the free Matomo Heatmap Helper extension do it.

The no-code route is a free Chrome extension we maintain, Matomo Heatmap Helper. It finds every fixed and sticky header on the page, takes them out of fixed/sticky positioning for the capture, and restores them afterward. The rest of this post is how to do the same thing yourself: the permanent CSS worth shipping if you capture these pages regularly, and the console route for when you can't edit the site at all.

The clean fix: scope CSS to Matomo's heatmap class

When Matomo captures a heatmap, it adds a class to the <html> element of the page it renders: matomoHeatmap for heatmaps, or matomoHsr for heatmaps and session recordings together. Any CSS you scope under that class applies only while Matomo is rendering the capture, and never to real visitors. That's the hook you want.

Add this to your site's stylesheet and edit the selectors to match your own header and overlay markup:

css
/* Matomo adds html.matomoHeatmap only while it captures the snapshot,
   so this never affects real visitors. Match your own markup. */
html.matomoHeatmap header,
html.matomoHeatmap nav,
html.matomoHeatmap [role="banner"],
html.matomoHeatmap .navbar,
html.matomoHeatmap .cookie-bar {
  position: static !important;
}

position: static drops the element back into normal document flow, so it renders once where it sits in the markup instead of repeating or freezing. Offsets and z-index are ignored automatically once an element is static, so there's nothing else to reset.

One caveat. A position: fixed header takes up no space in normal flow, and pages usually add top padding to make room for it. Force it static and it re-enters the flow, so you can end up with that padding plus the header's own height: a small gap at the top of the capture. Sticky headers already occupy their space, so they don't shift. For most pages the gap is cosmetic and the click data underneath stays readable. If it bothers you, drop the matching top padding inside the same html.matomoHeatmap block.

Matomo documents this hook for exactly this kind of problem; there's an official FAQ on correcting a heatmap when a header covers it. Whichever route you take, remember the screenshot is a stored snapshot: your change only shows up on a fresh capture. By default Matomo takes one automatically on page load, so to pick up your CSS you regenerate the heatmap. To control the timing (or force a fresh one when a bad screenshot is already stored), use manual capture, below.

Hiding a header instead of unsticking it

Sometimes a heatmap of the header isn't worth saving anyway. Visitors scroll, the header travels with them, and the clicks on it pile into a smear down the page. If the nav isn't what you're trying to learn from, hide it for the capture instead of unsticking it. Use visibility: hidden, not display: none:

css
html.matomoHeatmap header,
html.matomoHeatmap .cookie-bar {
  visibility: hidden !important;
}

The difference matters. display: none removes the element from layout entirely, and if you catch an in-flow <nav> that way, everything below it shifts up and your heatmap no longer lines up with the page. visibility: hidden keeps the element's box in place and just stops painting it, so nothing moves. For elements you want gone from every heatmap, Matomo also has a built-in hide-elements setting you can configure per heatmap instead of writing CSS.

When you can't edit the site CSS

If you don't control the stylesheet, a client's site or a CMS you can't touch, you can do the same thing from the browser console at capture time. There's one wrinkle: by default Matomo grabs the snapshot automatically on page load, before you've pasted anything. So first switch the heatmap to manual capture.

Applies toMatomo HSR 5.1.0+ for manual capture

Manual capture landed in the Heatmap & Session Recording plugin 5.1.0. Edit the heatmap, turn on Capture Heatmap Snapshot Manually, and save. Now Matomo waits for you to tell it when the page is ready instead of snapshotting on load.

Then, on the target page, see what's actually anchored. Open DevTools (F12), go to the Console, and list every fixed or sticky element:

js
// Logs every element whose computed position is fixed or sticky
Array.from(document.querySelectorAll('*'))
  .filter(el => ['fixed', 'sticky'].includes(getComputedStyle(el).position))
  .forEach(el => console.log(getComputedStyle(el).position, el));

On a typical marketing page you'll find more than one: the main header, a cookie bar, a back-to-top button, maybe a sticky sub-nav. They each break the capture in their own way.

Next, unstick them all and take the snapshot. The Matomo tag exposes a _paq array, and HeatmapSessionRecording::captureInitialDom is its manual-snapshot call. The number is your heatmap's ID, which you'll find in its URL or settings:

js
// Drops every fixed/sticky element back into normal flow, then tells
// Matomo to capture. Replace 7 with your heatmap's ID.
(() => {
  let unstuck = 0;
  document.querySelectorAll('*').forEach(el => {
    const pos = getComputedStyle(el).position;
    if (pos === 'fixed' || pos === 'sticky') {
      el.style.setProperty('position', 'static', 'important');
      unstuck++;
    }
  });
  console.log(`Unstuck ${unstuck} elements.`);
  _paq.push(['HeatmapSessionRecording::captureInitialDom', 7]);
})();

Run the block and Matomo captures the page with everything sitting in normal flow. If the first snippet logged nothing and you can still see a stuck header, it's likely inside a shadow DOM or an iframe the query didn't reach: inspect the element, confirm its position, and target it directly.

Same caveat as the CSS route. Forcing a fixed header static can add a small gap at the top where the page had reserved space for it. For most pages that shift is cosmetic and the heatmap stays readable.

Why Matomo renders sticky headers wrong

Matomo doesn't screenshot your page the way a browser would. When the heatmap is created, the tracker serializes the page's DOM and sends it to your Matomo server. Later, when you open the heatmap, the dashboard re-renders that stored DOM as a single static image and draws the click overlay on top.

What's missing in that second step is a live, scrolling viewport. position: fixed pins an element to the viewport; position: sticky flips between relative and fixed as a scroll container passes a threshold. Both need the browser to be scrolling a real viewport to know where the element belongs. Re-rendering a stored DOM as one image, there's no scroll and no viewport for them to attach to, so the outcome isn't defined. In practice you see one of two failure modes: the header repeated down the page at several positions, or frozen at a single spot covering whatever is behind it.

  1. 1

    Visitor loads the page

  2. 2

    Matomo tracker serializes the live DOM and records clicks

  3. 3

    Snapshot and click events stored on your Matomo server

  4. 4

    Dashboard re-renders the stored DOM as one static image

    no live viewportFixed and sticky elements have nothing to anchor to
  5. 5

    Click heatmap drawn over that image

Matomo re-renders a stored DOM without a live scrolling viewport, so fixed and sticky elements lose their anchor.

The clicks themselves were recorded against the live page, so the underlying data is usually fine. It's the rendered surface they're drawn on that's wrong, which is why unsticking the elements before capture fixes the picture without touching the data.

What's actually failing

A few patterns we keep running into:

  • The main site header is position: fixed, so it repeats down the page, dropping the same nav across the top of every section below the fold.
  • A cookie consent bar is position: fixed to the bottom of the viewport and shows up halfway down the screenshot, sitting on top of the pricing table or the product grid.
  • A back-to-top button or chat widget is position: fixed in a corner and gets stamped over every section, scattering click markers across content that has nothing to do with it.
  • A sub-nav with position: sticky and a high z-index freezes at one scroll position, and every click below it on the page lands on top of the sub-nav instead of the content.
  • A modal or banner the visitor had already dismissed still shows up, because the dismissal lived in JavaScript state and the re-rendered DOM started fresh from the serialized markup.

If your heatmap shows repeated nav, a frozen overlay, or click markers piled on header pixels, you're hitting at least one of these. A site with both a main header and a cookie bar is usually hitting two.

This is the same family of problem that breaks fonts, images, and scroll containers in the same screenshot. There's a longer post on broken Matomo heatmap screenshots that covers the rest, plus focused ones on fonts and images, since those come up nearly as often.

What we'd do

If you'll capture these pages more than once or twice, ship the html.matomoHeatmap CSS. It's a few lines, scoped so real visitors never see it, and after that the workflow is just regenerating the heatmap. That's the one we reach for.

If you can't edit the site's CSS, switch the heatmap to manual capture and paste the unstick-and-snapshot snippet. Less tidy, but it works on any page you can open DevTools on.

For client work across a lot of pages, where shipping code into someone else's stack isn't an option, the Matomo Heatmap Helper extension does it automatically: it detects every fixed and sticky element, neutralizes them for the capture, and restores them after, with the same handling for fonts, images, and scroll containers. 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 one small block of CSS. Worth setting up once.