A consent banner does not have to come from a cloud provider. CookieConsent by Orest Bida is a small MIT-licensed JavaScript library that you ship with your website. It asks about categories such as statistics and marketing, remembers the decision, releases scripts only after consent and deletes cookies again when someone declines. Matomo hooks into it through callbacks.
The principle: measure cookieless, cookies after consent
Matomo measures every visit without cookies and without browser feature detection. If someone accepts the statistics category, Matomo sets cookies from then on and recognises the visitor. If they decline or withdraw consent, Matomo deletes the cookies. The basic numbers stay complete, and whoever agrees is recognised across several visits. What is missing without cookies is covered in Matomo without cookies.
Adding it, self-hosted
CookieConsent is available as the npm package vanilla-cookieconsent, in November 2026 at version 3.3. From its dist folder you need two files, cookieconsent.umd.js and cookieconsent.css, plus a file with your texts. Put all three on your server; the examples in the documentation load them from a CDN, which is not necessary.
<link rel="stylesheet" href="/assets/cookieconsent/cookieconsent.css">
<script src="/assets/cookieconsent/cookieconsent.umd.js"></script>
<script>
CookieConsent.run({
categories: {
necessary: { enabled: true, readOnly: true },
analytics: {
// delete Matomo cookies when statistics is declined
autoClear: { cookies: [{ name: /^_pk_/ }, { name: 'mtm_cookie_consent' }] }
},
marketing: {}
},
language: {
default: 'en',
translations: { en: '/assets/cookieconsent/en.json' }
},
onConsent: syncConsent, // on the first decision and on every page after
onChange: syncConsent // when someone changes their selection
});
</script>
A few CookieConsent defaults already fit: the mode is opt-in, so scripts only run after consent. The accept and reject buttons carry equal weight. And the decision is kept for 182 days in the cc_cookie cookie. With revision, the banner asks again when you change services or categories.
Matomo without Tag Manager
The tracking code gets one extra line, requireCookieConsent. With it, Matomo measures from the start, but without cookies:
<script>
var _paq = window._paq = window._paq || [];
_paq.push(['requireCookieConsent']); // cookieless until consent arrives
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = 'https://matomo.example.com/';
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', '1']);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
})();
</script>
The callback that CookieConsent calls above translates the decision into Matomo commands. Define it before CookieConsent.run:
<script>
function syncConsent() {
window._paq = window._paq || [];
if (CookieConsent.acceptedCategory('analytics')) {
_paq.push(['rememberCookieConsentGiven']); // cookies allowed from now on
} else {
_paq.push(['forgetCookieConsentGiven']); // delete cookies, back to cookieless
}
}
</script>
rememberCookieConsentGiven stores consent in the mtm_cookie_consent cookie; forgetCookieConsentGiven deletes it and the Matomo cookies. Commands via _paq reach every Matomo tracker on the page, including the one from a Tag Manager container.
With Matomo Tag Manager
With the Tag Manager, the callback does not control Matomo directly but sends the decision to the data layer. What happens next is set in the container, for ad tags too:
<script>
function syncConsent() {
window._mtm = window._mtm || [];
window._mtm.push({
event: 'cc_consent',
cc_analytics: CookieConsent.acceptedCategory('analytics') ? 'granted' : 'denied',
cc_marketing: CookieConsent.acceptedCategory('marketing') ? 'granted' : 'denied'
});
}
</script>
In the container you need:
| What | Type | Setting |
|---|---|---|
| Matomo Configuration | variable | Require cookie consent on |
cc_analytics, cc_marketing |
Data-Layer variable | Data Layer Variable Name cc_analytics or cc_marketing |
| Consent statistics | Custom Event trigger | event cc_consent, condition cc_analytics equals granted |
| Rejection statistics | Custom Event trigger | event cc_consent, condition cc_analytics equals denied |
| Consent marketing | Custom Event trigger | event cc_consent, condition cc_marketing equals granted |
| Matomo Analytics | tag | on Pageview as before, measures cookieless until consent |
| Allow Matomo cookies | Custom HTML tag | _paq.push(['rememberCookieConsentGiven']), trigger Consent statistics |
| Remove Matomo cookies | Custom HTML tag | _paq.push(['forgetCookieConsentGiven']), trigger Rejection statistics |
| Ad tags | for example Facebook Pixel or Google Tag | trigger Consent marketing instead of Pageview |
In both Custom HTML tags, window._paq = window._paq || []; comes before the command so it also works while Matomo is still loading.
Because CookieConsent calls onConsent on every page where a decision exists, the cc_consent event arrives on every page, and the ad tags fire there, just only after consent. How variables, triggers and versions work in the Tag Manager is covered in Matomo Tag Manager. After every change, publish a new version of the container.
Controlling scripts from Google, Meta and others
Ad scripts may only load after consent. There are three ways to do this:
Through the script tags. CookieConsent only releases a script once its category is accepted if you mark it with type="text/plain" and data-category. With data-service, it gets its own toggle in the banner:
<script type="text/plain" data-category="marketing" data-service="Meta Pixel">
/* Meta Pixel code */
</script>
<script type="text/plain" data-category="marketing" data-service="Google Ads"
data-src="https://www.googletagmanager.com/gtag/js?id=AW-XXXXXXX"></script>
Through the Tag Manager. If you maintain ad tags in Matomo Tag Manager, attach them to the Consent marketing trigger, as in the table above.
Through Google’s Consent Mode. Google tags have their own consent signals. They start as denied and are set to granted in the callback:
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('consent', 'default', {
ad_storage: 'denied', ad_user_data: 'denied',
ad_personalization: 'denied', analytics_storage: 'denied'
});
// in the callback, when marketing is accepted:
// gtag('consent', 'update', { ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted' });
</script>
How many pixels you need at all is a separate question: conversions can also be reported to ad platforms from the server, described in Conversions API.
Check in the browser
A banner is only as good as what the browser actually does. Open the website in a private window with the developer tools:
- Before the decision: under Storage or Application there are no cookies starting with
_pk_. In the network tab, requests go tomatomo.php, but none to Facebook, Google or other ad networks. - After accepting: the cookies
_pk_id,_pk_sesandmtm_cookie_consentappear, and the ad scripts load. - After rejecting or withdrawing: the Matomo cookies are gone, Matomo keeps measuring cookieless, and ad scripts do not load.
Whether your website already sends data to third parties before the decision is also shown by the free website check.
How the banner fits Matomo
- Cookieless first Matomo always measures, cookies only after consent.
- All self-hosted Script, styles and texts on your own server.
- Reject on equal terms Equally weighted buttons, nothing preselected.
- One callback for everything Matomo, Tag Manager and ad scripts follow the same decision.
Typical pitfalls
- Matomo blocked instead of cookieless Without consent, all numbers are missing.
- Pixels directly in the template They load before anyone is asked.
- No sync on withdrawal The cookies remain although someone declined.
- Never checked in the browser Banner and reality differ.
Sources
As of 13 November 2026, CookieConsent 3.3, Matomo 5.14 and 6.0.0-rc1
