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

> Matomo heatmap screenshots fall back to Times New Roman or Arial when your @font-face files get blocked by CORS, locked to a hosted-font provider, or loaded after the snapshot is taken. Here's why it happens and how we deal with it.

Published: 2026-05-11
Categories: Matomo Heatmap Helper
Canonical: https://martez.io/blog/matomo-heatmap-fonts-not-loading

---

If you've opened a Matomo heatmap and the brand typeface is gone, hero set in Times New Roman, buttons set in Arial, the clicks themselves are fine. Matomo ties each interaction to the element it landed on, not to a fixed pixel, so the data stays usable even when the font falls back. What's broken is the screenshot behind it. Your brand fonts didn't make it into the saved snapshot, so the reconstructed page picked whatever system font it could find, and that's what you're staring at right before a CRO review.

<Callout type="tip" title="TL;DR">

Heatmap screenshots fall back to Times New Roman or Arial when the brand `@font-face` never made it into the saved snapshot, or made it in but the font file gets blocked by CORS or a hosted-font provider when Matomo rebuilds the page. The rule goes missing for two common reasons: it lives in a cross-origin stylesheet the browser couldn't read, or it was added at runtime with `new FontFace()`, which leaves no markup to save. The fix that lasts is to self-host the fonts you're licensed to self-host, so they load same-origin and there's no CORS to negotiate. Need one clean capture today, paste the console snippet below to embed every readable font as base64, or run the extension; just know it can't reach fonts locked cross-origin or served only through Adobe's embed code.

</Callout>

If you just need a screenshot today, the fastest path is a free Chrome extension we maintain, [Matomo Heatmap Helper](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd). It embeds every readable font on the page as base64 right before each capture, no stylesheet changes needed. The rest of this post is how to do the same thing by hand, and further down, why the fonts go missing in the first place.

## How to fix it without the extension

There's a console snippet that papers over the problem right before a capture, and a few permanent fixes you can ship with the site. Pick whichever fits your stack. There's also a JS pattern at the end that breaks the screenshot no matter which fix you ship, worth knowing about either way.

### Diagnose what's actually failing

Before you change anything, find out which fonts aren't loading. Open the page in Chrome, hit F12 for DevTools, switch to the Console, and paste:

```js
// Lists every font on the page and whether it loaded
document.fonts.ready.then(() => {
  [...document.fonts].forEach(f =>
    console.log(`${f.family} ${f.weight} ${f.style} → ${f.status}`));
});
```

Anything with a status that isn't `loaded` is a candidate. Then go to DevTools → Network, filter by "Font", and watch for 403 or CORS errors on your `@font-face` URLs.

One catch: a font can show as `loaded` here and still be missing from the snapshot. `document.fonts` reports the browser's runtime state, so a font added by JavaScript counts as loaded even though there's no `@font-face` rule in the markup for Matomo to save. So check the other half too: for each family you care about, confirm there's a real `@font-face` rule in an accessible stylesheet or a `<style>` tag before the snapshot is taken. If the family loads but has no rule in the DOM, that's a serialization problem, not a CORS one, and the fixes are different.

### The quick fix: embed every readable font as base64 from the console

This is the snippet we paste into the console right before triggering a capture. It walks the `@font-face` rules it can reach, fetches the font binaries, base64-encodes them, and injects a fresh `<style>` tag with self-contained data URIs. By the time Matomo saves the DOM, the fonts are baked in.

<Callout type="tip" title="This snippet can't bypass CORS">
It runs from the page, so its `fetch()` calls inherit the page's CORS rules. It embeds every font the browser is *allowed* to read. If a stylesheet or font file is blocked cross-origin, the snippet can't force it through; it leaves the original URL as a fallback and logs a warning. Those warnings are your list of URLs that still need a CDN, provider, or licensing fix. The extension can reach some of those via a background script, which the page console can't.
</Callout>

```js
// Paste into the browser console. Embeds every readable @font-face as base64.
(async () => {
  const fontFaces = [];

  const parseSrc = (srcValue, baseUrl) => {
    const out = [];
    const re = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")\s]+)['"]?\s*\))?/gi;
    let m;
    while ((m = re.exec(srcValue)) !== null) {
      const raw = m[1], format = m[2];
      if (raw.startsWith('data:')) { out.push({ url: raw, format }); continue; }
      try { out.push({ url: new URL(raw, baseUrl).href, format }); } catch {}
    }
    return out;
  };

  const collect = (rule, baseUrl) => {
    if (rule instanceof CSSFontFaceRule) {
      const s = rule.style;
      const family = s.getPropertyValue('font-family').replace(/['"]/g, '').trim();
      const src = s.getPropertyValue('src');
      if (!family || !src) return;
      fontFaces.push({
        family,
        weight: s.getPropertyValue('font-weight') || '',
        style: s.getPropertyValue('font-style') || '',
        display: s.getPropertyValue('font-display') || '',
        unicodeRange: s.getPropertyValue('unicode-range') || '',
        src: parseSrc(src, baseUrl),
      });
    } else if (rule.cssRules) {
      for (const r of Array.from(rule.cssRules)) collect(r, baseUrl);
    }
  };

  // 1. Walk accessible stylesheets via the CSSOM
  const crossOriginUrls = [];
  for (const sheet of Array.from(document.styleSheets)) {
    const baseUrl = sheet.href || location.href;
    try {
      for (const rule of Array.from(sheet.cssRules)) collect(rule, baseUrl);
    } catch {
      if (sheet.href) crossOriginUrls.push(sheet.href);
    }
  }

  // 2. Re-fetch cross-origin stylesheets as text and regex-parse @font-face blocks.
  //    This only works when the stylesheet's host allows the fetch.
  const parseCssText = (txt, baseUrl) => {
    const re = /@font-face\s*\{/gi;
    let m;
    while ((m = re.exec(txt)) !== null) {
      const start = m.index + m[0].length;
      let depth = 1, i = start;
      while (depth > 0 && i < txt.length) {
        if (txt[i] === '{') depth++; else if (txt[i] === '}') depth--;
        i++;
      }
      if (depth !== 0) continue;
      const body = txt.substring(start, i - 1);
      const get = n => {
        const r = new RegExp(n + '\\s*:\\s*([^;]+)', 'i').exec(body);
        return r ? r[1].trim() : '';
      };
      const family = get('font-family').replace(/['"]/g, '').trim();
      const src = get('src');
      if (!family || !src) continue;
      fontFaces.push({
        family,
        weight: get('font-weight'),
        style: get('font-style'),
        display: get('font-display'),
        unicodeRange: get('unicode-range'),
        src: parseSrc(src, baseUrl),
      });
    }
    // Recurse into @import
    const importRe = /@import\s+(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])/gi;
    const imports = [];
    while ((m = importRe.exec(txt)) !== null) {
      const u = m[1] || m[2];
      try { imports.push(new URL(u, baseUrl).href); } catch {}
    }
    return imports;
  };

  const visited = new Set();
  const fetchCss = async (url) => {
    if (visited.has(url)) return;
    visited.add(url);
    try {
      const txt = await fetch(url).then(r => r.text());
      const imports = parseCssText(txt, url);
      for (const u of imports) await fetchCss(u);
    } catch (e) { console.warn('CSS fetch failed:', url, e.message); }
  };
  for (const url of crossOriginUrls) await fetchCss(url);

  if (fontFaces.length === 0) { console.warn('No @font-face rules found.'); return; }
  console.log(`Found ${fontFaces.length} @font-face rules.`);

  // 3. Fetch each unique font binary and base64-encode it
  const uniqueUrls = [...new Set(
    fontFaces.flatMap(f => f.src.filter(s => !s.url.startsWith('data:')).map(s => s.url))
  )];
  console.log(`Fetching ${uniqueUrls.length} font files...`);

  const dataUris = new Map();
  await Promise.all(uniqueUrls.map(async (url) => {
    try {
      const blob = await fetch(url, { mode: 'cors' }).then(r => {
        if (!r.ok) throw new Error('HTTP ' + r.status);
        return r.blob();
      });
      const dataUri = await new Promise((res, rej) => {
        const r = new FileReader();
        r.onload = () => res(r.result);
        r.onerror = rej;
        r.readAsDataURL(blob);
      });
      dataUris.set(url, dataUri);
    } catch (e) { console.warn('Font fetch failed:', url, e.message); }
  }));

  // 4. Build new @font-face CSS using data URIs (with original URLs as fallback)
  const css = fontFaces.map(f => {
    const srcParts = f.src.map(s => {
      const u = dataUris.get(s.url) || s.url;
      return s.format ? `url("${u}") format("${s.format}")` : `url("${u}")`;
    });
    if (srcParts.length === 0) return '';
    const props = [`font-family: "${f.family}"`, `src: ${srcParts.join(', ')}`];
    if (f.weight)       props.push(`font-weight: ${f.weight}`);
    if (f.style)        props.push(`font-style: ${f.style}`);
    if (f.display)      props.push(`font-display: ${f.display}`);
    if (f.unicodeRange) props.push(`unicode-range: ${f.unicodeRange}`);
    return `@font-face {\n  ${props.join(';\n  ')};\n}`;
  }).filter(Boolean).join('\n\n');

  // 5. Inject into <head> so Matomo's snapshot sees self-contained fonts
  const style = document.createElement('style');
  style.setAttribute('data-heatmap-font-fix', 'true');
  style.textContent = css;
  document.head.appendChild(style);

  console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} font files across ${fontFaces.length} @font-face rules.`);
  console.log(dataUris.size === uniqueUrls.length
    ? 'All font files embedded. Now capture the heatmap snapshot.'
    : 'Some font files could NOT be embedded (see warnings above). Fix those URLs before capturing.');
})();
```

Read the last line, not the fact that a `<style>` tag exists. `Embedded 6/6` means every font is now self-contained and you're clear to capture. `Embedded 0/6` means the snippet changed nothing useful. Anything in between means some fonts are still pointing at external URLs that will likely fail in the reconstructed page. The warnings tell you which: `CSS fetch failed` is a stylesheet CORS problem, `Font fetch failed` is the font file (CORS, provider auth, or a 403), and `No @font-face rules found` usually means the fonts are loaded at runtime with no rule to save.

You can also confirm the tag landed, but treat the count as the real signal:

```js
// Confirms the injected styles are in <head> and how many use data URIs
const tag = document.querySelector('style[data-heatmap-font-fix]');
if (!tag) {
  console.log('No injected style tag found, re-run the embed snippet.');
} else {
  const rules = (tag.textContent.match(/@font-face/g) || []).length;
  const inlined = (tag.textContent.match(/url\("data:/g) || []).length;
  console.log(`${inlined}/${rules} @font-face rules now use base64. `
    + (inlined === rules ? 'Good to capture.' : 'Some still point at external URLs.'));
}
```

That's the quick path. It's for one-off captures and the review cycles where you need a clean screenshot now and can't wait on a code change. For anything you re-capture regularly, ship one of the permanent fixes below.

### Self-host the fonts (the canonical fix)

The cleanest answer for most people, as long as your font license allows self-hosting (more on that below for Adobe Fonts). Download the woff2 and woff variants you actually use from [google-webfonts-helper](https://gwfh.mranftl.com/fonts), drop them in `/public/fonts`, and ship CSS like this:

```css
/* Edit the family name and file paths to match your downloaded files */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-400.woff2") format("woff2"),
       url("/fonts/inter-400.woff") format("woff");
}
```

Now the fonts come from your own domain, same origin as the page, so there's no CORS to negotiate and the snapshot keeps its typeface. It's also faster for real visitors, since you've removed a third-party connection from every page load. Usually a couple of hours of work and a small CSS diff.

### Fix CORS on your CDN

If you'd rather keep fonts on a CDN, the fix is bigger than just the font files. Matomo's CORS guidance is mostly about the stylesheet: when your `@font-face` rules live in a cross-origin CSS file the browser can't read, the rule never makes it into the saved DOM, so the font files don't matter yet. You have to open up both.

So: set `Access-Control-Allow-Origin` on the stylesheet that contains the `@font-face` rules *and* on the font binaries, and add `crossorigin="anonymous"` to the stylesheet link and any font preload:

```html
<!-- Edit the URLs to your CDN's paths -->
<link rel="stylesheet" href="https://your-cdn.example.com/fonts.css"
      crossorigin="anonymous">
<link rel="preload" as="font" type="font/woff2"
      href="https://your-cdn.example.com/inter.woff2"
      crossorigin="anonymous">
```

Scope `Access-Control-Allow-Origin` to the origin shown in the browser's CORS error rather than reaching for `*`, and keep these headers limited to CSS and font files. Matomo explicitly warns against applying broad CORS headers to every resource. This works for Cloudflare, Fastly, AWS, anything you control. It does not work for hosted-font providers, which is the next section.

<Callout type="question" title="Can I self-host Adobe Fonts (Typekit)?" collapsable>
Short answer: not the files Adobe serves. Adobe's [web font terms](https://helpx.adobe.com/fonts/using/webfont-licensing.html) require the fonts to load through their embed code, and don't permit you to self-host or re-upload the webfont files. So the base64 snippet and "just download and self-host" are both off the table for Adobe-served fonts unless you buy a separate self-hosting license from the foundry (some foundries also offer a "bring your own license" path).

Two more things worth knowing. Current Adobe Fonts web projects [don't use a domain allow-list](https://helpx.adobe.com/fonts/using/domains.html), so before you assume a permissions problem, confirm the real network error in DevTools. And the practical options when Adobe is the blocker are: switch the affected text to a font you're licensed to self-host, use a foundry "bring your own license" font, or accept the fallback in the heatmap screenshot while keeping the real site on Adobe.
</Callout>

### Bake the embed into your build

If you don't control the stylesheet but you do control the build, run the same logic the console snippet runs, but at build time. Spin up a headless browser (Playwright or Puppeteer), execute the embed code against your built page, capture the resulting `<style>` block, and write it into your HTML template. The page ships with fonts already embedded, no runtime fetch, no console paste before each capture. (Same licensing caveat applies: only embed fonts you're allowed to.)

This is the heaviest of the three permanent fixes. It also covers the case where the marketing site runs on a CMS that won't let you touch the stylesheet but does let you ship a build script.

### The serialization gotcha

One more thing that catches people. Fonts loaded via JavaScript at runtime don't survive the snapshot:

```js
// BROKEN, won't survive Matomo's snapshot
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);
```

The font is registered with the browser's `document.fonts` set, but it isn't in the HTML Matomo saves. The screenshot falls back to system defaults even though the font is "loaded" in your tab. A `<style>` tag with `@font-face` rules or a normal `<link rel="stylesheet">` is fine, because those are real markup the snapshot can capture. The trap is `document.fonts.add()` and any CSS a framework injects *after* the snapshot is taken; rules that exist before capture are safe, runtime-only `FontFace` objects never are.

## Why Matomo can't load your fonts

Matomo's heatmap doesn't take a picture. When the first matching page view happens, the tracker running in the visitor's browser saves your page's DOM to Matomo. Later, when you open the heatmap, the UI rebuilds that DOM in a frame and overlays the click and scroll data on top. Matomo's docs are explicit that CSS, fonts, and images aren't frozen into that snapshot forever; they can still be read at capture time or loaded from your site when you view the heatmap.

<FlowSteps
  caption="Where fonts drop out of the heatmap pipeline"
  steps={[
    {
      title: "Visitor's browser",
      detail: "tracker saves the DOM",
      branch: {
        label: "@font-face CSS not readable, or rule missing",
        title: "System-font fallback",
      },
    },
    { title: "Snapshot stored in Matomo" },
    {
      title: "Heatmap UI reconstructs the page",
      branch: {
        label: "font file blocked by CORS or provider",
        title: "System-font fallback",
      },
    },
    { title: "Fonts render", tone: "ok" },
  ]}
/>

So a font has two ways to go missing. Either the `@font-face` rule wasn't readable when the DOM was saved, so it never entered the snapshot, or the rule made it in but the font file can't be loaded when the heatmap is reconstructed. Both end the same way: system-font fallback.

## What's actually failing

The patterns we keep running into:

- The CDN serving your fonts doesn't return `Access-Control-Allow-Origin`. Your live site tolerates that on the same origin; the heatmap's reconstructed page, pulling cross-origin, doesn't.
- The stylesheet holding your `@font-face` rules is cross-origin and unreadable, so the rule never makes it into the saved DOM. Font-file CORS alone won't help here; the CSS has to be readable too.
- Hosted-font providers like Adobe Fonts only serve through their own embed code and reject self-hosting or re-fetching of the files. You can't base64 your way around that, and you can't legally download and self-host without a separate license.
- Fonts loaded with `new FontFace(...).load()` then `document.fonts.add(...)` aren't in the saved markup at all. The snapshot reads the DOM, not the runtime FontFace set.

If your heatmap is showing the wrong font, you're hitting at least one of these, often more than one.

If you've seen providers like `fonts.gstatic.com` blocked specifically, check the actual Network error before blaming the provider; Google Fonts is generally CORS-friendly, and in most cases the real culprit is one of the four above.

This is the same family of problem that breaks images, scroll containers, and sticky headers in the same screenshot. We've written a [longer post on broken Matomo heatmap screenshots](/blog/fix-broken-matomo-heatmap-screenshots) that covers the rest.

## What we'd actually do

If you control the stylesheet and your license allows it, self-host. It fixes the heatmap, drops a third-party dependency, and speeds up the page for real visitors.

If you can't self-host but the fonts are yours to embed, base64 them into your existing stylesheet. Same effect on the snapshot, no infrastructure change.

If neither is reachable, the [Matomo Heatmap Helper](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd) Chrome extension is what we run on client sites where we can't change the stack. It does the same embed as the snippet above, plus the equivalent fixes for images, scroll containers, and sticky headers, on every capture. Free, open source, [code on GitHub](https://github.com/martez-io/matomo-heatmap-helper).

[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-heatmap-fonts-not-loading) 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](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-heatmap-fonts-not-loading) if that's relevant to you.

The fonts are usually fixable in a stylesheet. Worth doing once.
