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. On the live page they sit neatly inside the cards they belong to. In the captured screenshot they're stranded against the page corner.
An absolutely positioned overlay (a badge, tooltip, or dropdown) drifts to the top-left of the screenshot when none of its ancestors are positioned, so it has nothing to anchor to during capture. Give the container that should own it position: relative, then recapture. This only helps position: absolute overlays; fixed and sticky ones anchor to the viewport no matter what you do to their parents.
If you can edit the site's CSS, the fix is usually a few lines, and it's a genuine bug fix that helps live visitors too. If you can't (the overlay lives in a third-party widget, a CMS template, or a shared component library you don't own), the free Matomo Heatmap Helper extension we maintain gives static elements a positioning context during capture and restores them afterward, so the screenshot anchors overlays without you touching the codebase. The rest of this post is the CSS fix, 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 overlays, a one-liner that anchors them on the live page, and a CSS rule worth shipping permanently. This one is unusual: the permanent fix is usually safe for production, once you've confirmed the right container. More on that at the bottom of the section.
Find the overlays that have nothing to anchor to
Open the page in Chrome, hit F12 to open DevTools, switch to the Console, and paste this. It logs every absolutely positioned element whose ancestors are all position: static, which means it has nothing to anchor to and falls back to the page:
// Paste into the browser console.
// Logs absolutely positioned elements with NO positioned ancestor,
// i.e. the ones that drift to the page corner during capture.
Array.from(document.querySelectorAll('*'))
.filter(el => getComputedStyle(el).position === 'absolute')
.filter(el => {
let p = el.parentElement;
while (p) {
if (getComputedStyle(p).position !== 'static') return false; // already anchored, fine
p = p.parentElement;
}
return true; // no positioned ancestor, this one drifts
})
.forEach(el => console.log(el));This is the important difference from a naive scan. It doesn't log every wrapper that happens to contain an absolute child somewhere inside it. Most of those children already have a positioned ancestor and render fine. It logs only the absolute elements that genuinely have nowhere to anchor. On a well-built page it stays quiet. Note that it ignores fixed elements on purpose: position: relative on a parent does nothing for a fixed child, which anchors to the viewport no matter what its ancestors do. If your drifting element is fixed or sticky, that's the sticky-header family of problem instead.
Anchor them on the live page right now
Same filter, but instead of logging each drifting element it gives that element's parent a positioning context, so the overlay anchors to its container. Use this to confirm the fix lands before you commit any CSS:
// Paste into the console. Anchors each drifting overlay to its parent.
Array.from(document.querySelectorAll('*'))
.filter(el => getComputedStyle(el).position === 'absolute')
.filter(el => {
let p = el.parentElement;
while (p) {
if (getComputedStyle(p).position !== 'static') return false;
p = p.parentElement;
}
return true;
})
.forEach(el => { if (el.parentElement) el.parentElement.style.position = 'relative'; });The immediate parent is usually the right host for a badge or tooltip, but not always. If an overlay snaps to a spot that's close but still off, the container it should anchor to is higher up the tree: walk up from the logged element in DevTools and add position: relative to the card or section that should own it instead.
Recapture the heatmap after you run this (see below). If the overlays now sit inside their cards, you've found the right containers. Keep that list and write the permanent rule against it.
Matomo doesn't recapture on its own after you change CSS; it reuses the stored screenshot. On Matomo 5.1+ you can trigger a manual snapshot: enable Capture Heatmap Snapshot Manually for the heatmap, then run _paq.push(['HeatmapSessionRecording::captureInitialDom', {idHeatmap: ID}]) in the console once the page (and your CSS change) is in place. On older versions, delete the stored screenshot for the heatmap and let Matomo regenerate it from the next sample.
The permanent CSS fix
A short rule, edited for your site:
/* Match the actual containers the diagnostic snippet pointed at */
.card,
.feature-section,
[data-overlay-host] {
position: relative;
}Pick the selectors from what the diagnostic 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 on elements that carry a z-index, adding one changes how they stack relative to their neighbours, which can reorder layers elsewhere on the page.
Why relative and not absolute or fixed
position: relative keeps the element exactly where it is. No reflow, nothing visible changes. It only 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 shift the layout, which is rarely what you want on a wrapper.
Usually safe to ship, with one check
position: relative on the intended container isn't a heatmap-only hack. It's a real bug fix, and the overlay becomes more reliable for live visitors too. The cases where the layout happens to look right without a positioning context are exactly the cases where any small layout change will break it later.
The check: make sure the container you're position-relative-ing is the one the overlay was meant to anchor to. An absolute element that was deliberately anchored to the body or viewport will move if you give a closer ancestor a positioning context. After the change, click through dropdowns and modals and anything with a z-index to confirm nothing else shifted.
If you only want the fix for the heatmap and not for live visitors (say the overlay belongs to a third-party widget you'd rather not touch in production), scope it with Matomo's html.matomoHeatmap class, which Matomo only applies while it renders the heatmap:
html.matomoHeatmap .card,
html.matomoHeatmap [data-overlay-host] {
position: relative;
}Why this happens
The cause is CSS positioning, 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 renders the element wherever the absolute coordinates land, 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 shows it, with JavaScript repositioning it at runtime. The DOM Matomo captures can predate that runtime state, so the element's CSS-default position is what gets stored, and that default is "stuck against the page corner."
The element might sit at top: 0; left: 0; with its parent placed at the top of the page, so the broken positioning happens to look correct by coincidence. Anything that shifts the parent during capture (a sticky header that gets neutralized, a scroll container that expands, a hidden modal that becomes visible) breaks the coincidence.
The element might anchor to a positioned ancestor that's collapsed or hidden during normal browsing but gets exposed when something expands containers for capture. (As far as we've confirmed, that expansion comes from a capture workaround or extension rather than Matomo core, but the outcome is the same: the live page hides the bug, the screenshot exposes it.)
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) in the corner. The card 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, so they land somewhere plausible. Once capture re-flows anything above the card, the badges pin to the top-left. - A dropdown menu inside a header that uses
position: absolutefor the panel. On the live page the header isposition: sticky, 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 built with
position: absoluteand JS-driven coordinates. It's hidden in normal use, so nobody notices the CSS default is broken. When capture makes hidden elements visible, the tooltip surfaces at the page 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 lands at top-left. - Click markers that overlay correctly on the visible buttons, but the buttons themselves moved 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 shifted 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 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 covers 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 the containers that should own those overlays. Confirm the host is the right one, recapture, and you're done. It pays off on the live page too.
When the suspect overlays 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 reach for on client sites where we can't change the stack. It adds a positioning context to static elements during each capture and restores them afterward, so the screenshot gets a stable anchor without code changes. 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 short stylesheet edit. Worth doing once.