02 Text článku
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
pnpm 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:
"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>:
#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
<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:
type 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.
const 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:
({ /* ... */ 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:
import { run } from 'vanilla-cookieconsent';
()
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 fakenavigator.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.autoClearper category. The patterns are per vendor and have to be maintained with the vendor list:_gaand_gidfor GA4,_clckand_clskplusCLIDandMUIDfor Clarity, the_gcl_family for the Ads conversion linker,_fbpand_fbcfor 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:
function 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:
private 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
onAccepthook before the decision-levelonConsentandonChange, so a push fromonAcceptreaches GTM while the type is still denied and the tag is skipped for that page view. That is why the events are pushed fromonConsentandonChange, right after the update, and the per-service hooks stay unused.onConsentruns on every load that already has a decision,onChangeon every later edit,onFirstConsentonce. 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.
A preferences link, anywhere
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.
<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:
function 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 */
}
}
export 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:
private 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:
const 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:
The cookie jar and localStorage are empty before any interaction.
After accepting one category, only that category’s vendors wrote anything.
After withdrawing, the page reloaded and storage is empty again.
The GA4 hits carry
gcsandgcdparameters 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.
