Since March 2024 Google requires Consent Mode v2 from any site in the EEA that wants Google Ads measurement and audiences to keep working. The banner libraries all say they support it. What none of them do for you is the wiring: where the defaults go in an Angular app, what the banner has to tell Google Tag Manager and in what order, what happens on withdrawal, and how you find out how many visitors said no. This is the full setup, in the order you build it, with vanilla-cookieconsent as the banner and GTM running GA4, Google Ads, Microsoft Clarity and the Meta pixel.

One decision first. Consent Mode has two shapes. In Advanced mode the Google tags load before the visitor decides and send cookieless pings while consent is denied, which Google uses to model the conversions it cannot see. In Basic mode nothing Google loads until the visitor accepts. Advanced buys modeled data only above traffic thresholds Google does not publish, and it leaves you a cookieless ping to explain to a regulator. This article builds Basic: nothing touches the visitor’s browser storage before a choice. Most of the steps apply to Advanced too, but the gates below are what make Basic actually hold.

Install the banner

Bashpnpm add vanilla-cookieconsent

The library ships one stylesheet. Add it to the styles array of the build so it is bundled with the app rather than fetched at runtime:

JSON"styles": [ "node_modules/vanilla-cookieconsent/dist/cookieconsent.css", "src/styles.scss" ]

It is themed entirely through its own custom properties on the #cc-main root it appends to the body. Map them onto your design tokens once and the banner follows the site, including dark mode if your tokens flip with a class on <html>:

SCSS#cc-main { --cc-font-family: var(--app-font); --cc-bg: var(--app-paper); --cc-primary-color: var(--app-ink); --cc-link-color: var(--app-accent); --cc-btn-primary-bg: var(--app-accent); --cc-btn-primary-color: var(--app-paper); --cc-modal-border-radius: 0; --cc-btn-border-radius: 0; }

No separate dark-mode wiring is needed. The library’s own .cc--darkmode class exists, but if #cc-main sits inside your themed <html> the tokens already carry the theme.

The defaults, in index.html, above the GTM snippet

HTML<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', personalization_storage: 'denied', functionality_storage: 'granted', security_storage: 'granted', cc_ga4: 'denied', cc_clarity: 'denied', cc_ads: 'denied', cc_pixel: 'denied', wait_for_update: 500, }); </script> <!-- Google Tag Manager snippet -->

The first seven keys are Google’s. ad_user_data and ad_personalization are the two that v2 added; a block without them is not v2, however the banner looks. wait_for_update holds tags for up to half a second so a stored decision can be replayed before anything fires.

The four cc_* keys are yours. They are custom consent types, one per vendor, and they exist because of a GTM rule that is easy to miss: a tag’s “Require additional consent” check treats a consent type that was never set as granted. A vendor type that is not defaulted here leaves that vendor’s tags open to an undecided visitor.

Why keep the defaults at all when, in Basic mode, no tag should load before a choice? Because the container is edited in a UI by people who are not reading your repository. A tag put on an All Pages trigger fires at container load. With the defaults in place such a tag sends a cookieless ping and writes nothing. Without them, Google tags run as if everything was granted.

Give gtag a type once, where the service lives, so the rest of the code does not cast:

TypeScripttype ConsentState = 'denied' | 'granted'; type GoogleSignal = | 'ad_personalization' | 'ad_storage' | 'ad_user_data' | 'analytics_storage' | 'personalization_storage'; declare const window: Window & { dataLayer: { event: VendorSlug }[]; gtag: ( command: 'consent', action: 'update', signals: Record<GoogleSignal | VendorSlug, ConsentState>, ) => void; };

One place that knows the vendors

Everything downstream keys off one list: the vendor slugs, grouped by the category the banner shows. Each slug is three things at once: the service the visitor can tick, the custom consent type above, and the event name GTM triggers on.

TypeScriptconst VENDORS = { analytics: { 'Google Analytics': 'cc_ga4', 'Microsoft Clarity': 'cc_clarity', }, marketing: { 'Google Ads': 'cc_ads', 'Meta pixel': 'cc_pixel', }, } as const; type VendorSlug = | (typeof VENDORS.analytics)[keyof typeof VENDORS.analytics] | (typeof VENDORS.marketing)[keyof typeof VENDORS.marketing]; export const VENDOR_SLUGS: readonly VendorSlug[] = Object.values(VENDORS).flatMap( group => Object.values(group), );

A unit test that reads index.html and asserts every slug in VENDOR_SLUGS has a denied default is the cheapest insurance in this whole setup. Add a vendor in code, forget the default, the build fails.

The service

The banner runs in the browser only. On the server there is no document, and a prerendered page must not carry a decision. So the shell component starts it behind a platform check, and the service itself is provided by that shell rather than at root, so a per-language shell can hand it that language’s strings:

TypeScript@Component({ /* ... */ providers: [CookieConsentService] }) export class ShellComponent { private readonly platformId = inject(PLATFORM_ID); private readonly consent = inject(CookieConsentService); constructor() { if (isPlatformBrowser(this.platformId)) { void this.consent.activate(); } } }

The service is one run() call with the configuration the library documents, plus the three callbacks the rest of this article is about:

TypeScriptimport { run } from 'vanilla-cookieconsent'; @Injectable() export class CookieConsentService { private readonly i18n = inject(TRANSLATION); async activate(): Promise<void> { await run({ mode: 'opt-in', revision: 1, cookie: { useLocalStorage: true }, hideFromBots: true, manageScriptTags: false, onConsent: ({ cookie }) => { syncGoogleConsent(cookie.categories, slugsOf(cookie.services)); this.pushVendorEvents(slugsOf(cookie.services)); }, onFirstConsent: ({ cookie }) => { this.reportChoice(cookie, true); }, onChange: ({ cookie, changedCategories, changedServices }) => { syncGoogleConsent(cookie.categories, slugsOf(cookie.services)); const accepted = new Set(slugsOf(cookie.services)); this.pushVendorEvents( slugsOf(changedServices).filter(slug => accepted.has(slug)), ); this.reportChoice(cookie, false); if (withdrewSomething(cookie, changedCategories, changedServices)) { window.location.reload(); } }, guiOptions: { consentModal: { layout: 'cloud inline', position: 'bottom center', flipButtons: true }, preferencesModal: { layout: 'box', flipButtons: true }, }, categories: { necessary: { enabled: true, readOnly: true }, analytics: { autoClear: { cookies: [{ name: /^(_ga|_gid|CLID|MUID|_clck|_clsk)/ }] }, services: labelsOf(VENDORS.analytics), }, marketing: { autoClear: { cookies: [{ name: /^(_gcl_|_fbp|_fbc)/ }] }, services: labelsOf(VENDORS.marketing), }, }, language: { default: this.i18n.code, translations: { [this.i18n.code]: this.i18n.cookies }, }, }); } }

The options that matter and why:

  • mode: 'opt-in'. Nothing is granted until the visitor acts. The alternative, opt-out, is not a Basic-mode option.

  • revision. Bump it whenever the vendor list or the texts change in a way that needs a fresh decision. The library shows the banner again to everyone whose stored decision carries an older revision.

  • cookie.useLocalStorage: true. The decision itself is stored in localStorage instead of a cookie. One less cookie to explain, and it survives the reload below.

  • hideFromBots: true. Crawlers and headless browsers never see the banner. Remember this in your tests; it is why they have to fake navigator.webdriver.

  • manageScriptTags: false. The library can toggle <script> tags by itself. With GTM in the picture that is a second, competing gate; GTM owns the tags, the service only talks to GTM.

  • autoClear per category. The patterns are per vendor and have to be maintained with the vendor list: _ga and _gid for GA4, _clck and _clsk plus CLID and MUID for Clarity, the _gcl_ family for the Ads conversion linker, _fbp and _fbc for the pixel.

  • language.translations. The library wants its strings per language code. If the app already has an i18n layer, hand it that layer’s object rather than maintaining a second copy.

labelsOf and slugsOf are two small helpers: one turns a vendor group into the { slug: { label } } shape the library wants under services, the other flattens the library’s { category: [slugs] } record of accepted services into a plain slug list.

What the callbacks tell GTM, and in what order

Two things travel from the banner to the container, and the order is the part that costs a week when it is wrong.

First, a consent update with Google’s category signals and one state per vendor type:

TypeScriptfunction syncGoogleConsent(categories: string[], accepted: VendorSlug[]): void { const analytics = state(categories.includes('analytics')); const marketing = state(categories.includes('marketing')); const set = new Set(accepted); window.gtag('consent', 'update', { analytics_storage: analytics, ad_storage: marketing, ad_user_data: marketing, ad_personalization: marketing, personalization_storage: marketing, ...Object.fromEntries(VENDOR_SLUGS.map(slug => [slug, state(set.has(slug))])), }); if (marketing === 'denied') { clearAdLocalStorage(); } } const state = (granted: boolean): ConsentState => (granted ? 'granted' : 'denied');

Second, one dataLayer event per accepted vendor:

TypeScriptprivate pushVendorEvents(slugs: VendorSlug[]): void { slugs.forEach(slug => window.dataLayer.push({ event: slug })); }

In GTM each vendor’s loader tag sits on a Custom Event trigger named after its slug, and every tag the vendor owns, including the ones fired later by a form success or a link click, lists the slug under “Require additional consent”. That covers both halves: the cc_ads event loads the Ads library, and the conversion tag that fires on a form submit is checked against the cc_ads type at that moment.

The rules:

  • A rejection has no event. Pushing an event is what fires a tag, so a denial can only travel as the consent update. Do not invent a “declined” event.

  • The update goes first, the events second, from the same callback. GTM evaluates a tag’s consent check when its event is processed. vanilla-cookieconsent runs a service’s own onAccept hook before the decision-level onConsent and onChange, so a push from onAccept reaches GTM while the type is still denied and the tag is skipped for that page view. That is why the events are pushed from onConsent and onChange, right after the update, and the per-service hooks stay unused.

  • onConsent runs on every load that already has a decision, onChange on every later edit, onFirstConsent once. None of them runs before the visitor has chosen, so an undecided visitor keeps the denied defaults from step 2 for the whole visit.

  • Events are per vendor, not one generic consent_update. Google’s signals are per category, and the preferences modal lets a visitor accept analytics but untick Clarity. A category event could not express that.

Clarity deserves a note: it reads Google consent mode as its own consent signal and runs cookieless for EEA visitors without one, so the update above gates it even before its own type is checked.

The visitor must be able to come back to the decision. The library handles that without a line of JavaScript: any element with a data-cc attribute opens the matching modal.

HTML<button type="button" data-cc="show-preferencesModal"> Cookie preferences </button>

Put it in the footer. Because the attribute is read by the library at click time, the button works on pages rendered on the server too, as soon as the service has run.

Withdrawal has to erase, and then reload

Accepting is the easy direction. When a visitor withdraws, three things have to happen.

The library’s autoClear removes the cookies matched by the category’s patterns. That is cookies only. The Ads linker also mirrors its identifier into localStorage as _gcl_ls, and the Meta pixel keeps a lastExternalReferrer pair there. autoClear cannot reach those, so a sweep runs on every load that has marketing denied, which is also what cleans up after any tag that fired before the gates existed:

TypeScriptfunction clearAdLocalStorage(): void { try { Object.keys(localStorage) .filter(key => key.startsWith('_gcl') || key.startsWith('lastExternalReferrer')) .forEach(key => localStorage.removeItem(key)); } catch { /* storage blocked: nothing was written either */ } }

TypeScriptexport function withdrewSomething( cookie: { categories: string[]; services: Record<string, string[]> }, changedCategories: string[], changedServices: Record<string, string[]>, ): boolean { const accepted = new Set(slugsOf(cookie.services)); return ( changedCategories.some(category => !cookie.categories.includes(category)) || slugsOf(changedServices).some(slug => !accepted.has(slug)) ); }

A withdrawal in the middle of a form loses the input. Decide whether to warn; most sites accept it as rare.

Counting the people who said no

So the service reports every decision to an endpoint of your own, and only the decision:

TypeScriptprivate reportChoice( cookie: { categories: string[]; services: Record<string, string[]> }, first: boolean, ): void { const body = JSON.stringify({ choice: choiceOf(cookie.categories), // 'all' | 'partial' | 'none' accepted: slugsOf(cookie.services), first, locale: this.i18n.code, }); void fetch('/api/consent', { method: 'POST', keepalive: true, headers: { 'content-type': 'application/json' }, body, }).catch(() => { /* a tally line lost to the network is not worth a retry */ }); }

fetch with keepalive, not HttpClient: the reload after a withdrawal cancels an XHR in flight, and the withdrawals are exactly the lines you want to keep.

The endpoint is a few lines of Express. It validates the body with a schema that has no field for an identifier, rate-limits per client address, and writes one structured log line:

TypeScriptconst choice = z.object({ choice: z.enum(['all', 'partial', 'none']), accepted: z.array(z.string().max(40)).max(20), first: z.boolean(), locale: z.enum(['en', 'cs']), }); app.post('/api/consent', rateLimit, (req, res) => { const parsed = choice.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid request' }); return; } console.log(JSON.stringify({ message: 'Consent choice', consent: parsed.data })); res.json({ ok: true }); });

A logs-based metric on the choice field charts the consent rate over time. No database, no cookie, no ID.

Audit the published container

The GTM UI shows the workspace. What visitors get is the published container, and it is readable.

What to check:

  • no vendor tag on All Pages or DOM Ready;

  • every tag that sends data lists its vendor type under consent, including the ones fired by a form success or a link click;

  • no measurement ID left over from a test property.

GTM’s built-in consent checks, the ones a Google tag applies on its own, are not in that list; only the additional ones you configured are. Both have to be in place.

Verify against the real thing

Check against the published container, not a mock. Playwright with navigator.webdriver faked to false, because hideFromBots skips the banner for automation, and four assertions:

  1. The cookie jar and localStorage are empty before any interaction.

  2. After accepting one category, only that category’s vendors wrote anything.

  3. After withdrawing, the page reloaded and storage is empty again.

  4. The GA4 hits carry gcs and gcd parameters that match the decision.

What it comes down to

A consent setup holds or fails on four things, and none of them is the banner. The defaults in index.html, above the container, with every custom type denied, so a tag nobody meant to put on All Pages still writes nothing. The order of what the banner tells GTM: the consent update first, the vendor events second, from the same callback, because a tag is checked at the moment its event is processed. A withdrawal that erases what was written, in cookies and in localStorage, and then reloads, because a library already in the page does not stop sending on its own. And a count of the decisions kept somewhere GA4 is not, because GA4 never meets the visitors who said no.

Everything else is configuration that follows from those four. One vendor list that is at once the service the visitor ticks, the consent type GTM checks and the event name that fires the loader. Script management left to GTM, so there is one gate and not two. A revision bumped whenever the list changes. A preferences button in the footer that the library wires itself. The published container read as JSON after every version, not the workspace. And a browser test with navigator.webdriver faked, asserting an empty jar before yes and an empty jar again after no.