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.

You open a Matomo heatmap, and where your hero video should be there's a black rectangle with click markers floating on top of it. The clicks are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. The video didn't paint when Matomo rebuilt the page for the heatmap, so that whole section is unreadable and the click on your CTA looks like a click on nothing.

The fastest fix, if you can't touch the site's code, 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 once the capture is done. If you can edit the markup, the durable fix is a poster attribute, and the rest of this post covers both paths.

TL;DR

A <video> element has nothing to paint until a frame is decoded, and Matomo's heatmap snapshot rarely catches one, so the video area renders as a black box on replay. Add a poster image to every <video> tag, then delete the stored heatmap screenshot so Matomo recaptures the page with the poster in place. If you can't edit the markup (vendor widgets, CMS hero modules), the Matomo Heatmap Helper extension does it for you at capture time.

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+ = a frame is decoded
console.log('Current time:', $0.currentTime);

Empty poster plus readyState: 0 is the failure mode. The element has no decoded frame, so there's nothing to paint when the snapshot is taken and it falls back to a black box. readyState >= 2 (HAVE_CURRENT_DATA) means the browser has a frame ready, and the snippets below can lock onto it.

Get Matomo to recapture the page

Applies toMatomo HSR 5.1+Manual capture

By default, Matomo's Heatmap & Session Recording captures its snapshot automatically when the page loads. Two things follow from that.

First, if Matomo already stored a bad snapshot, shipping a fix won't change the heatmap you're staring at. That snapshot is frozen. Delete the existing heatmap's screenshot/sample in Matomo so it captures a fresh one on the next qualifying visit.

Second, the console snippets below only help if Matomo captures the page while they're applied, and a page-load capture has already happened by the time you paste anything. To capture on demand, enable Capture Heatmap Snapshot Manually in the heatmap's settings (Matomo Heatmap & Session Recording 5.1.0 and up). That panel shows you the exact call to run. Apply your snippet first, then fire it from the console:

js
// Matomo's settings panel shows this line with your heatmap's ID filled in.
_paq.push(['HeatmapSessionRecording::captureInitialDom', idHeatmap]);

One gotcha worth knowing before you waste twenty minutes: manual capture won't run if your own IP is excluded from tracking. Test from an address that isn't excluded, or drop the exclusion while you test.

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

This snippet pauses all media and waits until each video is parked on a decoded first frame, so the renderer has something stable to capture. The await matters: seeking is asynchronous, and if you trigger the capture before the seeked event fires, you snapshot the black box anyway.

js
// Paste into the console, then trigger the manual capture once it resolves.
async function freezeForCapture() {
  document.querySelectorAll('audio').forEach(a => a.pause());
  const videos = [...document.querySelectorAll('video')];
  await Promise.all(videos.map(v => new Promise(resolve => {
    v.pause();
    const seekToStart = () => {
      v.currentTime = 0;
      v.addEventListener('seeked', resolve, { once: true });
    };
    // readyState >= 2 (HAVE_CURRENT_DATA) means a frame is decoded and paintable.
    if (v.readyState >= 2) seekToStart();
    else v.addEventListener('loadeddata', seekToStart, { once: true });
    setTimeout(resolve, 3000); // give up on a video that never decodes
  })));
}
await freezeForCapture();

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

It can't help a video that never decodes a frame, which is what readyState: 0 was telling you above. A preload="none" video that nobody has played has no pixels to seek to. For those, the next snippet tries to build a poster from a frame once one is available, and failing that you ship a real poster image.

Auto-generate posters from the first frame

If a video has a decoded frame, you can paint it onto a canvas, encode that as a data URI, and set it as the poster. The poster is a plain image, so it survives the snapshot cleanly:

js
// Run after freezeForCapture() resolves. Needs a decoded frame to read.
document.querySelectorAll('video').forEach(v => {
  if (v.poster || v.readyState < 2) return; // no frame yet (HAVE_CURRENT_DATA)
  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('Canvas tainted (cross-origin without CORS):', v.currentSrc);
  }
});

The gate on readyState >= 2 matters here. Metadata alone tells you the video's dimensions but not that any frame has been decoded, and drawImage needs actual pixels. The other catch is cross-origin: a video on a CDN that doesn't return CORS headers taints the canvas, and toDataURL throws. For that to work, the <video> has to carry crossorigin="anonymous" before the resource loads, and the CDN has to send Access-Control-Allow-Origin for your origin. Adding crossorigin after the fact doesn't un-taint anything. If you can't get both, ship a poster image yourself.

Always set a poster attribute (the canonical fix)

The cleanest answer for almost everyone. A poster is a static image, not a frame the browser has to decode, so it survives the snapshot 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 visitors. The poster shows while the video buffers, instead of the empty rectangle some browsers display by default. Better perceived load, fixed heatmap, one line of markup.

Generate posters in your build pipeline with ffmpeg

If you have more than a handful of videos and you're touching them anyway, wire poster generation into the 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 one-second mark usually beats frame zero, because the very first frame of an encoded video is sometimes a black or near-black keyframe before the content starts. Run it once per video, commit the JPG, set poster="hero-poster.jpg" in your template.

Swap to a static image during capture with the matomoHeatmap hook

Useful when the video itself can't change but you can edit the markup or CSS around it. Matomo adds a matomoHeatmap class to the <html> element while it captures a snapshot, so you can target that state in plain CSS, no JavaScript and no guessing at a tracker flag:

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

The <picture> shows up only during capture; the <video> stays visible to real visitors. 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.

Switch preload="none" to preload="metadata" (a minor improvement)

Treat this as a footnote, not the fix. preload="none" tells the browser to fetch nothing until the user hits play, which means readyState: 0 and no frame for the canvas trick to read. Bumping it to preload="metadata" lets the browser know the video's dimensions earlier:

html
<video src="hero.mp4" poster="hero-poster.jpg" preload="metadata"
       autoplay muted loop playsinline></video>

Just don't mistake it for a heatmap fix. metadata fetches things like duration and dimensions; it does not decode a frame (that's loadeddata / HAVE_CURRENT_DATA), preload is only a hint the browser can ignore, and with autoplay set the browser loads the video regardless. The poster is what makes the snapshot correct.

Why Matomo can't render your videos

Matomo's Heatmap & Session Recording runs in the visitor's browser. When the page loads, its JavaScript serializes a snapshot of the DOM, the page structure as it stood at that moment, and stores it on your Matomo server. Later, when you open the heatmap, Matomo rebuilds the page from that stored snapshot, loads the resources (CSS, images, fonts) from your site, and overlays the click data on top.

  1. 1

    Visitor's browser loads your page

  2. Heatmap JS serializes the DOM
    2

    Snapshot stored on Matomo

  3. you open the heatmap
    3

    Matomo rebuilds the page and overlays the clicks

  4. 4

    Video area is blank

    no poster or decoded frame existed at snapshot time

The clicks survive because they're coordinates. The video doesn't, because nothing was painted into its element when the snapshot was taken.

There's no live browser session in that replay, no autoplay timer, no decoded video sitting in memory. A <video> element is just an empty rectangle until something decodes a frame to paint into it. If the tag had no poster and no already-decoded frame at the moment the snapshot was taken, the stored structure has nothing to show, and the replay paints an empty box. Most renderers 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. Fine in a real browser, black rectangle in the heatmap, because the snapshot never caught a decoded frame.
  • Autoplay product carousels built on <video> elements (sometimes inside a <picture> for art direction). Same root cause as the hero video, multiplied across the page.
  • <video preload="none"> on lazy-loaded sections. Saves bandwidth for real visitors, leaves no decoded frame when Matomo takes the snapshot.
  • Background videos absolutely positioned behind text. The text shows up, the video doesn't, and the heatmap shows your hero copy floating over a black rectangle.
  • <audio> elements that affect layout (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 the canvas-poster trick hits a security error, so you're left 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.

It's the same family of problem that breaks fonts, images, 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, then regenerate the heatmap so Matomo recaptures with the poster in place. It's a one-line change per video, the heatmap renders correctly, and your real visitors get a better loading experience too.

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

If neither is reachable (vendor widgets, third-party embeds, CMS-driven hero modules where the editor uploads a video and there's no poster field), the Matomo Heatmap Helper Chrome extension is what we reach for 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.