If you've opened a Matomo heatmap and found your header stamped down the page two, three, sometimes six times like a postage mark, or one copy of it frozen across the middle covering the content underneath, the clicks themselves are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. Your sticky header didn't render the way it does in a browser, so it either repeats at every scroll position or pins itself to wherever the renderer happened to be looking. Either way, every click underneath it gets plotted on top of nav links, and a chunk of your real heatmap turns into one big dead zone.
The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It detects every fixed and sticky header on the page, swaps them to position: relative with a height-matched placeholder during capture, and restores them after. The rest of this post is how to do the same thing without an extension, plus a permanent CSS pattern worth shipping if you'll be capturing heatmaps regularly.
How to fix it without the extension
There's a console snippet that papers over the problem right before each capture, and a permanent CSS branch you can ship with the site. Pick whichever fits the constraints you're working under.
Find every fixed and sticky header on the page
Before you change anything, find out what's actually anchored. Open the page in Chrome, hit F12 to open DevTools, switch to the Console, and paste:
// Logs every header-like element and its computed position value
document.querySelectorAll('header, nav, [role=banner], [role=navigation], .header, .navbar, .nav, .sticky')
.forEach(el => console.log(getComputedStyle(el).position, el));Anything reporting fixed or sticky is a suspect. On most marketing sites you'll find more than one. The main header is fixed, the cookie bar is fixed, a "back to top" button is fixed, a secondary sub-nav is sticky. They all stack and they all break the screenshot in their own way.
Unstick every header right before the capture
Paste this before triggering the heatmap capture. It mirrors what the extension's sticky-header-fixer does: switch every fixed and sticky header to position: relative, and for fixed headers (which were detached from document flow) drop in an invisible same-height placeholder so scroll position and click coordinates don't shift.
// Paste into the browser console. Unsticks every fixed/sticky header so it
// doesn't repeat or float across the heatmap, and inserts a placeholder
// with the same height/width to preserve the rest of the layout.
(() => {
const SELECTOR = 'header, nav, [role=banner], [role=navigation], .header, .navbar, .nav, .sticky';
let unstuck = 0;
document.querySelectorAll(SELECTOR).forEach(el => {
const cs = getComputedStyle(el);
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
// Capture original dimensions before changing position
const rect = el.getBoundingClientRect();
// Insert invisible placeholder for fixed (sticky already takes layout space)
if (cs.position === 'fixed' && el.parentElement) {
const ph = document.createElement('div');
ph.style.height = rect.height + 'px';
ph.style.width = rect.width + 'px';
ph.style.visibility = 'hidden';
ph.dataset.heatmapPlaceholder = 'true';
el.parentElement.insertBefore(ph, el);
}
// Convert to relative so it joins document flow and stops repeating
el.style.position = 'relative';
el.style.top = 'auto';
el.style.bottom = 'auto';
el.style.left = 'auto';
el.style.right = 'auto';
el.style.zIndex = 'auto';
el.style.width = 'auto';
unstuck++;
});
console.log(`Unstuck ${unstuck} headers. Now trigger your Matomo heatmap capture.`);
console.log('To revert: location.reload(), or remove the placeholders by hand.');
})();Run it, look at the count, trigger the capture. If it logs Unstuck 0 headers and you can still see a repeating header on the page, the selector list above is missing whatever class your header actually uses. Inspect the element, find the wrapping element with position: fixed, and add its selector to SELECTOR.
Or just hide the header entirely during capture
A heatmap on a sticky header rarely tells you anything useful anyway. Visitors scroll, the header moves with them, and every click on the nav lands at a different page-Y coordinate. The result is a heatmap of the nav that's smeared across the entire page height. If the header isn't what you're trying to learn from, the simplest move is to take it out of the picture entirely:
// Hides every header during the capture
document.querySelectorAll('header, nav, [role=banner], .navbar, .sticky')
.forEach(el => el.style.display = 'none');The capture comes back as if the header weren't there. Real clicks underneath still land on the right elements, because the click coordinates don't change. You just lose the (mostly useless) heatmap data on the nav itself.
Permanent CSS fix scoped to capture mode
If you're capturing heatmaps on the same pages repeatedly, the console-paste workflow gets old. Better is a CSS branch that activates only when you visit the URL with a flag like ?matomo_heatmap=1. Add a class to <html> when the flag is present:
// Add to your site (or your tag manager)
if (new URLSearchParams(location.search).has('matomo_heatmap')) {
document.documentElement.classList.add('heatmap-mode');
}Then ship CSS that unsticks every header inside that class:
/* Edit selectors to match your site's header markup */
html.heatmap-mode header,
html.heatmap-mode nav,
html.heatmap-mode [role=banner],
html.heatmap-mode .navbar {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
width: auto !important;
z-index: auto !important;
}Now the capture workflow is just: visit ?matomo_heatmap=1, trigger the heatmap, done. No console paste, no risk of forgetting. The downside is the placeholder trick (preserving the original layout for position: fixed headers) is harder to express in pure CSS, so the page may shift slightly when you flip the class. For most sites that's fine. The click data is what matters, and the visuals stay close enough to the real layout to be readable.
Why Matomo can't render sticky headers right
Matomo's heatmap renderer takes the serialized DOM and rasterizes it as a single tall image, the full height of the page from the top of <body> all the way to the bottom. There's no scrolling in that pipeline. There's no viewport the way a real browser has a viewport. It's one big canvas.
A header with position: fixed is detached from document flow and pinned to whatever the current viewport is. When the renderer is producing one tall canvas with no viewport, the result is undefined. Some renderers stamp the header at every scroll step they would have made, so it repeats every 600 to 1000 pixels. Others lock it to one arbitrary y-coordinate and call it done. position: sticky runs into the same problem from the opposite direction: the sticky boundary is defined relative to a scrolling ancestor, and there's no scrolling ancestor when the page renders as one image.
Either way, the visual output is wrong, and the clicks (which were recorded against real page coordinates by the in-browser tracker) end up plotted on top of header pixels that aren't in the right place.
What's actually failing
A few patterns we keep running into:
- The main site header is
position: fixed, and the renderer stamps it every 600 to 1000 pixels down the page. The hero, the value props, the testimonials, every one of them has the same nav floating across the top of it. - A cookie consent bar is
position: fixedto the bottom of the viewport. It shows up halfway down the screenshot, covering the pricing table or the product grid. - A "back to top" button or floating chat widget is
position: fixedin the bottom-right. It appears stamped over every section, dropping click markers all over content that has nothing to do with the button. - A sub-nav uses
position: stickywith a highz-indexand gets locked at one arbitrary scroll position by the renderer. Every click below that y-coordinate, anywhere on the page, gets plotted on top of the sub-nav. - A modal or banner that the visitor dismissed before clicking anything still shows up in the screenshot, because the dismiss state lived in JS and the renderer started fresh from the serialized DOM.
If your heatmap is showing repeated nav, frozen overlays, or click coordinates plotted on top of header pixels, you're hitting at least one of these. On any site with both a main header and a cookie bar, you're hitting two.
This is also the same family of problem that breaks fonts, images, and scroll containers in the same screenshot. We've written a longer post on broken Matomo heatmap screenshots that walks through the rest, plus separate posts on fonts and images since those come up almost as often.
What we'd actually do
If you're going to capture heatmaps on the same pages more than a couple of times, ship the ?matomo_heatmap=1 CSS branch. It's a few lines of CSS, you set it up once, and the workflow afterwards is just a query parameter. The slight layout shift from skipping placeholders is almost always fine.
If you don't control the templates or the CSS, paste the unstick snippet before each capture. Not pretty, but it works on every page you can open DevTools on.
For client work where we're capturing across a lot of pages and don't want to ship code into someone else's stack, the Matomo Heatmap Helper Chrome extension does this automatically. It detects every fixed and sticky header, swaps them to position: relative with a height-matched placeholder, runs the capture, and restores everything after. The same logic covers fonts, images, and scroll containers in one shot. 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 CSS branch away. Worth doing once.