If you've opened a Matomo heatmap and found a tooltip pinned to the top-left corner of the page, a "New" badge floating in white space above your hero, or click markers landing a hundred pixels off the buttons they belong to, the clicks themselves are fine. Matomo recorded them at the right coordinates. What's broken is where the overlays inside your screenshot ended up. The live page shows them sitting neatly inside the cards they belong to. The captured screenshot shows them stranded against the page corner.
The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It walks the DOM right before each capture, finds containers that hold absolutely positioned children but are themselves position: static, and flips them to relative so the overlays anchor where the design expected. The rest of this post is how to do the same thing without an extension, plus why the live page hides the bug in the first place.
How to fix it without the extension
There's a console snippet that finds the offending containers, a one-liner that fixes them on the live page, and a CSS rule worth shipping permanently. This one is unusual: the permanent fix is safe for production. More on that at the bottom of the section.
Find the containers that need a positioning context
Open the page in Chrome, hit F12 to open DevTools, switch to the Console, and paste this. It logs every section, article, card, or wrapper that holds at least one absolutely positioned child but is itself position: static:
// Paste into the browser console. Logs every static container with absolute children.
Array.from(document.querySelectorAll('section, article, .card, [class*="container"], [class*="wrapper"]'))
.filter(el => getComputedStyle(el).position === 'static')
.filter(el => Array.from(el.querySelectorAll('*'))
.some(child => ['absolute','fixed'].includes(getComputedStyle(child).position)))
.forEach(el => console.log(el));Each logged element is a place where an overlay child has nowhere to anchor and falls back to the viewport. The selector list is broad on purpose. Tweak it for your stack if you use Tailwind, BEM, or CSS modules where the class names look different.
Apply the fix on the live page right now
Same selector as above, but it sets position: relative on each match instead of logging it. Use this if you want to confirm the fix lands before committing CSS:
// Paste into the console. Gives every offending container a positioning context.
Array.from(document.querySelectorAll('section, article, .card, [class*="container"], [class*="wrapper"]'))
.filter(el => getComputedStyle(el).position === 'static')
.filter(el => Array.from(el.querySelectorAll('*'))
.some(child => ['absolute','fixed'].includes(getComputedStyle(child).position)))
.forEach(el => el.style.position = 'relative');Trigger the heatmap capture right after. If the overlays now sit inside their cards, you've found the right containers. Keep that list, ship the next snippet against them.
The permanent CSS fix
Three lines, edited for your site:
/* Edit the selectors below to match your site's overlay containers */
.card,
.feature-section,
[data-overlay-host] {
position: relative;
}Pick the selectors based on what the diagnostic snippet above logged. Don't go broader than you need to. A blanket * { position: relative; } is the wrong move. Most elements don't need a positioning context, and adding one creates new stacking contexts that can break z-index ordering in unrelated parts of the page.
Why relative and not absolute or fixed
position: relative keeps the element exactly where it was. No reflow, nothing visible changes. It just opts the element in as an anchor for its absolutely-positioned children. absolute and fixed both pull the element out of the document flow and would shift the layout, which is rarely what you want on a wrapper.
Safe to ship to everyone
Unlike most heatmap-specific fixes, this one is also a real bug fix on the live page. Tooltips and dropdowns become more reliable for actual visitors too. The cases where the layout coincidentally looks fine without a positioning context are the cases where any small layout change will break it. There's no need to gate the CSS behind a cookie or a query param. Ship it as part of the normal stylesheet.
Why this happens
CSS positioning is the issue, not Matomo. An element with position: absolute is placed relative to its nearest positioned ancestor. "Positioned" means anything other than position: static, which is the default for everything until you say otherwise. If every ancestor up the tree is static, the absolute element falls all the way back to the initial containing block, which is, in practice, the viewport. The browser doesn't warn you. It just renders the element wherever the absolute coordinates land in the viewport, which is usually the top-left.
So why does it look fine on the live page? A few reasons.
The element might be hidden until a hover or click event triggers it, and JavaScript repositions it at runtime. The screenshot fires before the JS runs, or the JS doesn't run during capture at all, so the element's CSS-default position is what gets serialized, and that default is "stuck against the viewport."
The element might be at top: 0; left: 0; and the developer placed its parent at the top of the page, so the broken positioning happens to look correct by coincidence. Anything that shifts the parent's position during capture (a sticky header that gets unfixed, a scroll container that expands, a hidden modal that becomes visible) breaks the coincidence.
The element might be using position: absolute to anchor to a positioned ancestor that's invisible during normal browsing but gets exposed by the heatmap renderer's "expand all containers" pass. Same outcome: the live page lies, the screenshot tells the truth.
What's actually failing
A few patterns we keep running into:
- A card component with absolutely positioned badges (a "New" tag, a discount sticker, a count bubble) on the corner. The card itself is
position: staticbecause nobody addedposition: relative. The badges look fine on the live page only because the design happens to put the card flush against the viewport's left edge, and the badges land somewhere visually plausible. Once the heatmap capture re-flows anything above the card, the badges pin to the top-left of the page. - A dropdown menu inside a header that uses
position: absolutefor the panel. The header hasposition: stickyon the live page, which counts as a positioning context. During capture, sticky headers often get neutralized (otherwise they'd appear at every scroll position in the screenshot), and the dropdown loses its anchor. - A tooltip implemented with
position: absoluteand JS-driven coordinates. The tooltip is hidden in normal use, so nobody notices that the CSS default is broken. The heatmap renderer makes hidden elements visible to capture them, and the tooltip surfaces at the viewport corner. - A modal whose backdrop is
position: fixed(correctly) but whose content panel isposition: absoluteinside a static parent. The backdrop covers the screen, the panel ends up at top-left. - Click markers from Matomo overlay correctly on the visible buttons, but the buttons are misaligned because their layout shifted during capture, so the markers look like they're pointing at the wrong elements. The fix isn't on the markers; it's on whatever is shifting the buttons.
If your heatmap shows overlays in the wrong place, you're hitting at least one of these. Often more than one on the same page.
This is also 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.
What we'd actually do
Run the diagnostic snippet, look at what comes back, and ship a position: relative rule for those containers. It's a real bug fix, not a heatmap workaround, and it pays off on the live page too.
If the suspect containers live inside a third-party widget, a CMS template, or a shared component library you don't own, the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't change the stack. It flips static ancestors to position: relative during each capture and restores them after, so the screenshot gets a stable positioning context without touching the codebase. 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 three-line stylesheet edit. Worth doing once.