Why videos aren't loading in your Matomo heatmap (and how to fix it)

Matomo heatmap screenshots show black rectangles where your hero video, autoplay product reel, or background loop should be. The clicks are fine. Here's why the video doesn't paint and how we deal with it.

If you've opened a Matomo heatmap and found a black rectangle where your hero video should be, an empty box in place of the autoplay product reel, and click markers floating over a blank space where the CTA was, the clicks themselves are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. The video didn't paint when the Matomo server re-rendered the page, so the whole section of the heatmap is unreadable, and the click on top of the CTA looks like a click on nothing.

The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It pauses every video on the page, seeks to the first frame, and restores playback after the capture is done. The rest of this post is how to do the same thing without an extension, plus a few permanent fixes worth shipping.

How to fix it without the extension

There's a console snippet that papers over the problem right before each capture, and a handful of permanent fixes you can ship with the site. Pick whichever fits the constraints you're working under.

Diagnose what's actually failing

Before you change anything, find out what state the video is in. Open the page in Chrome, hit F12, click the video in the Elements panel so it becomes $0, then switch to the Console and paste:

js
// Paste into the console after selecting the video in the Elements panel
console.log('Poster:', $0.poster || '(empty)');
console.log('Ready state:', $0.readyState); // 0 = nothing, 2+ = current frame available
console.log('Current time:', $0.currentTime);

Empty poster plus readyState: 0 is the failure mode. The element has nothing to paint when the DOM gets serialized, so it falls back to a black box. readyState >= 2 with a non-zero currentTime means the browser has a frame ready, and the next snippet will lock onto it.

The quick fix: pause every video and seek to the first frame

This is the snippet we paste into the browser console right before triggering Matomo's heatmap capture. It pauses every <video> and <audio> on the page (so animations stop moving), and rewinds each video to the first decoded frame so the renderer has something stable to paint:

js
// Paste into the console. Freezes media on a stable, screenshotable frame.
document.querySelectorAll('video, audio').forEach(el => {
  el.pause();
  if (el.tagName === 'VIDEO' && el.readyState >= 1) el.currentTime = 0;
});

This is enough on its own for a lot of sites. The video stops, the first frame sits there until the screenshot is done, and the heatmap shows your CTA on top of the actual hero shot instead of on top of nothing.

It doesn't help if the video has no decoded frame yet, which is what readyState: 0 was telling you above. For those, the next snippet generates a poster from the first frame and applies it directly.

Auto-generate posters from the first frame

If the video has loaded enough metadata to know its dimensions, you can paint the first frame onto a canvas, encode that as a data URI, and set it as the poster. The renderer paints the poster, the heatmap looks right:

js
// Paste into the console. Paints the first frame onto a canvas and uses it as
// the poster. Works for same-origin videos and CORS-enabled cross-origin videos.
document.querySelectorAll('video').forEach(v => {
  if (v.poster) return;
  const c = document.createElement('canvas');
  c.width = v.videoWidth || 1280;
  c.height = v.videoHeight || 720;
  try {
    c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
    v.poster = c.toDataURL('image/jpeg');
  } catch (e) { console.warn('CORS-blocked video:', v.src); }
});

The catch: cross-origin videos served without Access-Control-Allow-Origin will throw a security error when you try to read the canvas. The console warning tells you which ones. For those, ship a poster image directly (next section), or proxy the video through your own domain.

Always set a poster attribute (the canonical fix)

The cleanest answer for almost everyone. A poster is a static image, not a frame the renderer has to paint, so it survives DOM serialization without any of the timing problems video has. Add it to every <video> tag and the heatmap stops being a guessing game:

html
<!-- Edit the file paths to your video and poster image -->
<video src="hero.mp4" poster="hero-poster.jpg" autoplay muted loop playsinline></video>

This is also better for real users. The poster shows up while the video is buffering, instead of the empty rectangle that some browsers display by default. Good for perceived performance, fixes the heatmap, costs you a one-line change.

Generate posters in your build pipeline with ffmpeg

If you have more than a handful of videos and you're editing them anyway, wire poster generation into your build step. ffmpeg is the obvious tool:

bash
# Edit the input filename. Grabs a frame at the 1-second mark.
ffmpeg -i hero.mp4 -ss 00:00:01 -frames:v 1 hero-poster.jpg

A second mark usually beats frame zero, since the very first frame of an encoded video is sometimes a black or near-black keyframe before the actual content starts. Run this once per video, commit the JPG, set poster="hero-poster.jpg" in your template. Done.

Swap to a static image in heatmap mode

Useful if the video itself can't change, but you can change the markup or CSS around it. Detect Matomo's capture (Matomo's tracker exposes a flag, or you can listen for the screenshot trigger and toggle a class), set .heatmap-mode on <html>, then in CSS:

css
/* Edit the selectors to match your markup */
html.heatmap-mode video.hero { display: none; }
html.heatmap-mode picture.hero-fallback { display: block; }

The <picture> shows up only during heatmap capture, the <video> stays visible to real users. A bit more wiring than just adding a poster, worth it when the video is owned by a vendor widget or a CMS field you can't touch directly.

Use preload="metadata" instead of preload="none"

preload="none" tells the browser not to fetch anything at all until the user hits play. That means readyState: 0, no decoded frame, nothing for the renderer to paint, and the canvas-poster trick above won't work either because there's no pixel data to read. preload="metadata" fetches enough to know the dimensions and decode the first frame, which is exactly what the screenshot needs:

html
<!-- Just enough preload for the renderer to have a frame ready -->
<video src="hero.mp4" preload="metadata" poster="hero-poster.jpg"
       autoplay muted loop playsinline></video>

Bandwidth cost is small (the first frame, not the whole file). Heatmap cost is zero. Worth flipping the default.

Why Matomo can't render your videos

Matomo's screenshot capture is a two-step process. First, the tracker serializes your live DOM into HTML and ships it off. Then, your Matomo server re-renders that HTML to produce the screenshot you see in the heatmap view. No browser session, no autoplay timer, no decoded video frames in memory. Just an HTML file being rendered cold from a different IP.

<video> elements are special. Even in a real browser, the rectangle a video occupies is empty until the browser decodes a frame to paint into it. Matomo's renderer doesn't play video. It serializes whatever the DOM says, and a <video> tag with no poster attribute and no already-decoded frame says "render an empty rectangle here." Most rendering engines fill that with black. That's the box you're seeing in the heatmap.

What's actually failing

A few patterns we keep running into:

  • Hero videos with autoplay muted loop and no poster attribute. Fine in a real browser, black rectangle in the screenshot, because the renderer never sees a decoded frame.
  • Autoplay product carousels built on <video> elements (sometimes inside a <picture> for art direction). Same root cause as the hero video, just multiplied across the page.
  • <video preload="none"> on lazy-loaded sections. Saves bandwidth for real users, leaves nothing for the renderer to paint.
  • Background videos absolutely positioned behind text content. The text shows up, the video doesn't, the heatmap shows your hero copy floating over a black rectangle.
  • <audio> elements that affect layout (sometimes used for ambient sound on game or music sites). Less common, same family of problem.
  • Cross-origin videos on a CDN that doesn't return CORS headers. Even if you try the canvas-poster trick, the security error blocks you, so you're stuck either with a poster image you ship yourself or a same-origin proxy.

If your heatmap shows a black box where a video should be, you're hitting at least one of these.

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 in your Matomo heatmap and why images aren't loading in your Matomo heatmap since those come up almost as often.

What we'd actually do

If you control the video markup, ship a poster attribute on every <video> tag. It's a one-line change per video, the heatmap renders correctly, and your real users get a better loading experience too.

If you control the build pipeline but not the templates, generate posters with ffmpeg as part of your build. Same effect, fewer manual steps.

If neither is reachable (vendor widgets, third-party embeds, CMS-driven hero modules where the editor uploads the video and there's no poster field), the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't change the stack. It pauses every video before capture, seeks to the first frame, runs the canvas-poster trick where CORS allows, and restores playback after. 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.

The poster attribute fix is usually a one-line change. Worth doing once.