Waarom je Matomo heatmap op een bepaald punt afkapt (en hoe je dat oplost)

Matomo heatmap-screenshots worden zwart of leeg onder een bepaalde lijn wanneer een sidebar, tabbenpaneel of een andere container met een vaste hoogte zijn overflow afknipt tijdens het vastleggen. De klikken worden gewoon geregistreerd, maar landen op lege ruimte. Hier lees je waarom het gebeurt en hoe we ermee omgaan.

Als je een Matomo heatmap hebt geopend en alles onder een bepaalde lijn vervangen zag door een zwart vlak, je sidebar ingeklapt op de hoogte die hij op het scherm had, en klikmarkers nutteloos gestapeld op een platte kleur — de klikken zelf zijn prima. Matomo heeft ze op de juiste coördinaten vastgelegd. Wat kapot is, is de screenshot eronder. Een container op je live pagina beperkte zijn eigen hoogte en liet content overlopen in een scrollgebied (de sidebar, het tabbenpaneel, de slide-over met de FAQ), en die container heeft zijn volledige content niet meegekregen in de vastgelegde DOM. De container hield zijn beperkte hoogte tijdens de screenshot-pass, en Matomo tekende het gebied voorbij die grens als lege ruimte.

De snelste fix is een gratis Chrome-extensie die wij onderhouden: Matomo Heatmap Helper. Vlak voor elke capture loopt hij elk element op de pagina langs, vindt de elementen waarvan de scrollHeight groter is dan hun clientHeight, en herschrijft hun height en max-height zodat de screenshot de volledige content ziet. Na de capture worden de originelen hersteld. De rest van dit artikel laat zien hoe je hetzelfde doet zonder extensie, plus een permanent CSS-patroon dat het waard is te shippen.

Hoe je het oplost zonder de extensie

Er is een consolesnippet dat het probleem vlak voor elke capture oplost, en een CSS-patroon via een query-param dat je kunt shippen om de UX voor je echte gebruikers intact te houden. Kies wat bij jouw stack past.

De schuldige elementen opsporen

Voordat je iets aanpast, zoek je de containers die afknippen. Open de pagina in Chrome, druk op F12 om DevTools te openen, ga naar de Console, plak dit, druk op Enter. Alles wat gelogd wordt met een verschil van meer dan vijf pixels is een kandidaat:

js
// Paste into the browser console on the affected page.
// Lists every element whose contents are clipped by a capped height.
Array.from(document.querySelectorAll('*'))
  .filter(el => el.scrollHeight > el.clientHeight + 1)
  .forEach(el => console.log(el.scrollHeight - el.clientHeight, el));

Kleine verschillen (één of twee pixels) zijn meestal subpixelafronding en niet jouw probleem. Alles wat honderden of duizenden pixels aan afgesneden content rapporteert, is wat je zoekt. Daar gaat de heatmap donker worden.

De snelle fix: alle afgesneden containers forceren te expanderen

Dit is de snippet die we vlak voor het triggeren van Matomo's heatmap-capture in de browserconsole plakken. Hij loopt elk element op de pagina langs, expandeert de elementen waarvan de content overloopt, en zet hun overflow-regels uit zodat de volledige content zichtbaar is. Tegen de tijd dat Matomo de DOM serialiseert, zijn de afgesneden secties uitgerold.

js
// Paste into the console. Expands every container that has hidden overflow.
document.querySelectorAll('*').forEach(el => {
  if (el.scrollHeight > el.clientHeight + 1) {
    el.style.height = el.scrollHeight + 'px';
    el.style.minHeight = el.scrollHeight + 'px';
    el.style.maxHeight = 'none';
    el.style.overflow = 'visible';
  }
});

Om te bevestigen dat er niets meer afgeknipt is voordat je de capture triggert:

js
// Returns the number of elements still clipping their contents. Should be zero.
Array.from(document.querySelectorAll('*'))
  .filter(el => el.scrollHeight > el.clientHeight + 1).length;

Als dat nul is, ben je klaar. Als dat niet zo is, zitten de resterende elementen meestal in shadow roots of cross-origin iframes waar de snippet niet bij kan. Meer daarover hieronder.

Dat is de snelle route. Het werkt voor eenmalige captures en de reviewcycli waarbij je nu een schone screenshot nodig hebt en niet wilt wachten op een codewijziging. Voor pagina's die je regelmatig opnieuw vastlegt, ship je de permanente fix hieronder.

De permanente fix: een heatmap-only stylesheet

De truc is om de UX voor je echte gebruikers intact te houden (sticky sidebars, scrollbare tabbenpanelen, alles wat er met een reden is) terwijl de heatmap-pass de volledige content ziet. Een CSS-klasse via een query-param is de eenvoudigste manier:

css
/* Edit the selectors below to match your site's containers */
html.heatmap-capture .sidebar,
html.heatmap-capture .tab-panel,
html.heatmap-capture [data-scrolls] {
  max-height: none !important;
  height: auto !important;
  min-height: 0 !important;
  overflow: visible !important;
}
js
// Add to your site. Turns the override on when you visit with ?heatmap=1
if (new URLSearchParams(location.search).get('heatmap') === '1') {
  document.documentElement.classList.add('heatmap-capture');
}

Nu zet een bezoek aan https://jouw-site.nl/pagina?heatmap=1 de pagina in capture-vriendelijke modus. Echte gebruikers bezoeken de pagina zonder de param en houden hun normale scroll-UX. Dezelfde site, twee layouts, één query-param ertussen.

Als je de trigger niet in de URL zichtbaar wilt hebben, vervang je de param-check door een cookie die vanuit een admin-only route wordt gezet, of door een User-Agent-check op Matomo's screenshot-fetcher (grep je accesslogs op de exacte UA-string die Matomo's renderer gebruikt en gate dan server-side). Zelfde idee, andere poort.

De afweging

Ship max-height: none niet naar elke bezoeker. Sticky sidebars, scrollbare tabbenpanelen en containers met een vaste hoogte bestaan met een reden: ze houden navigatie in beeld, laten panelen naast de rest van de layout bestaan, en voorkomen dat een 4.000 pixels hoge accordion de pagina overneemt. Het punt van de override achter een gate zetten is juist om productie te houden zoals echte gebruikers het verwachten en de layout alleen om te zetten tijdens het vastleggen.

Beproefde workflow: open de pagina in een incognitovenster met ?heatmap=1, klik rond om de heatmap-sessie vast te leggen, laat het tabblad open totdat Matomo's capture afgaat. Echte gebruikers op dezelfde pagina in hun gewone sessies zien de override nooit.

Waarom Matomo voorbij het afkappunt niet kan vastleggen

Matomo's screenshot-capture is een proces in twee stappen. Eerst serialiseert de tracker je live DOM naar HTML en stuurt die door. Dan rendert je Matomo-server die HTML tot de screenshot die je in de heatmapweergave ziet. De renderer tekent de pagina precies zoals de geserialiseerde DOM die beschrijft. Als een container in die DOM height: 600px en overflow: hidden heeft met content die eigenlijk 2.000 pixels hoog is, haalt alleen de bovenste 600 pixels het beeld. Alles eronder wordt afgeknipt bij de containergrens en het heatmap-canvas tekent het gebied voorbij die grens als lege ruimte (zwart, wit of transparant, afhankelijk van wat eronder zit).

De klikcoördinaten worden bijgehouden op documentniveau, dus die overleven het afknippen prima. De pixels waar ze naar wezen niet.

Wat er precies misgaat

Een paar patronen die we steeds tegenkomen:

  • Sidebars met max-height: calc(100vh - header) en overflow-y: auto, gebruikelijk bij mega-navigatie, facetfilters en chatpanelen. De sidebar is hoger dan de viewport op de live pagina. De screenshot ziet alleen de viewport-hoogte-slice.
  • Tabbenpanelen waarbij elk [role=tabpanel] zijn eigen scrollcontainer heeft, standaardgedrag in Bootstrap, Tailwind UI, MUI en de meeste componentbibliotheken. Het actieve tabblad wordt afgeknipt op zijn beperkte hoogte en de inactieve tabbladen zijn helemaal niet zichtbaar.
  • Drawer-achtige secties die eigenlijk geen modals zijn: slide-overs, uitgevouwen filtertrays, inline winkelwagenpanelen. Alles wat bewust begrensd is op 100vh of een vaste pixelhoogte. De container blijft begrensd tijdens de capture.
  • Dashboardkaarten met interne scrollers, tegels met vaste hoogte waarbij de content overloopt in een overflow-y: auto div. Matomo maakt een screenshot van de tegel op zijn buitenafmetingen en alles boven de zichtbare slice van de interne scroller is weg.
  • Componentbibliotheken die dit inbouwen: ScrollArea van shadcn/ui, MUI's Drawer met interne scroll, Tailwind UI's comboboxlijsten, Headless UI-tabbenpanelen. Goed om van te weten bij het auditen van de pagina — het schuldige element zit vaak drie lagen diep in een wrapper die je zelf niet hebt geschreven.
  • Iframes met beperkte afmetingen. De eigen DOM van het iframe wordt afgeknipt op de buitenafmetingen van het iframe, en Matomo kan sowieso niet in cross-origin iframes kijken.

Als je heatmap donker wordt voorbij een bepaalde lijn, loop je tegen minstens één van deze situaties aan. De meeste pagina's met een inhoudssidebar raken er twee tegelijk.

Dit is ook dezelfde familie van problemen die lettertypen, afbeeldingen en sticky headers in dezelfde screenshot breekt. We hebben een langere post over kapotte Matomo heatmap-screenshots geschreven die de rest behandelt, plus afzonderlijke posts over waarom lettertypen niet laden in je Matomo heatmap en waarom afbeeldingen niet laden in je Matomo heatmap.

Wat we zelf zouden doen

Als je de templates beheert, ship de gated stylesheet. Het zijn een handvol selectors en een query-param-check, en die blijven in productie zolang je Matomo heatmaps gebruikt. Vijf minuten werk, voor altijd bruikbaar.

Als dat niet kan, dekt de consolesnippet hetzelfde voor eenmalige captures. Het nadeel is dat je elke keer moet onthouden hem te plakken voordat je capture triggert.

Als geen van beide haalbaar is (CMS-locked templates, third-party widgets die je niet bezit, alles waarbij je geen CSS-regel of scripttag kunt toevoegen), is de Matomo Heatmap Helper Chrome-extensie wat we gebruiken op clientsites waar we de stack niet kunnen aanpassen. Die voert dezelfde scrollHeight/maxHeight-herschrijving uit tijdens de capture en herstelt de originelen daarna, plus equivalente fixes voor lettertypen, afbeeldingen en sticky headers. Gratis, open source, code op GitHub.

Martez is het grotere project waar de extensie uit is voortgekomen. Het verbindt Matomo met Meta Ads en Google Ads zodat ROAS, CLV en attributie naast je webanalytics staan in plaats van in een apart spreadsheet. Het is in private beta. Schrijf je in voor de wachtlijst als dat relevant voor je is.

Een paar selectors en een query-param. De moeite waard om één keer te doen.

Waarom je Matomo heatmap op een bepaald punt afkapt (en hoe je dat oplost) - Martez Blog