Managing cookie consent is a tangle of banners, laws, and provider APIs. Every analytics vendor wants a different knob, and one wrong default can tank your data or your compliance. That’s why we built a small, clear system that separates the UI, storage, and provider syncing. Easy to read, easy to test, easy to swap.
toggleConsentManagement()
. useConsent()
stores decisions in cookies for one year. useConsent
in composables/useConsent.ts
(your project’s version). // nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: { public: { consentPrefix: 'myapp' } }
})
app.vue
or a layout so it can appear on first visit. Example bellow shows how to connect Google Analytics, which is currently the only implemented one. But you can write your own provider plugin to connect any other service. (See the section bellow)
nuxtScripts
and add your GA ID: // nuxt.config.ts
scripts: {
registry: {
googleAnalytics: {
id: "G-XXXXXXXXXX",
},
},
},
plugins/ga.client.ts
If you need to connect a service that is not supported out of the box, you can create your own provider plugin. Here’s how:
plugins
directory, e.g. plugins/myProvider.ts
. defineNuxtPlugin
function: // plugins/myProvider.ts
export default defineNuxtPlugin(() => {
// your provider implementation
})
Centered consent modal with Accept, Reject, and Customize options. Granular toggles for analytics, ads, personalization, and functional cookies; persists via useConsent. Includes programmatic open/close.
cookie-icon
— Optional slot to replace the default cookie icon in the heading.@nuxt/icon
@nuxt/scripts
You may use these UI components in your own personal or commercial projects. You may not resell, redistribute, sublicense, or package them as standalone assets or template/library packs.
Full terms: End-User License Agreement
Below you can expand the main implementation file and any supporting components. Use the “Copy” button to grab a snippet straight to your clipboard.
These are the raw components that are required to run this example. Copy-paste them into your project. Most likely you will not change anything in these files, but you can if you want to. These are the components that are used in the main implementation file.
<script setup>
// store
const { consent, decided, acceptAll, declineAll, setConsent } = useConsent();
const advanced = ref(false);
// local overlay toggle so users can reopen after deciding
const show = ref(false);
function toggleConsentManagement() {
// sync latest values when opening from footer/link
if (!show.value) {
local.analytics = consent.value.analytics;
local.ads = consent.value.ads;
local.personalization = consent.value.personalization;
local.functional = consent.value.functional;
}
show.value = !show.value;
}
// local editable copy for the <details> panel
const local = reactive({
analytics: consent.value.analytics,
ads: consent.value.ads,
personalization: consent.value.personalization,
functional: consent.value.functional,
});
function onSave() {
setConsent({
analytics: local.analytics,
ads: local.ads,
personalization: local.personalization,
functional: local.functional,
});
show.value = false;
}
defineExpose({
toggleConsentManagement,
});
</script>
<template>
<!-- Positioned on the center -->
<div
class="p-4 max-w-4xl max-h-screen overflow-auto fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 font-iq-paragraph leading-iq-paragraph tracking-iq-paragraph text-iq-paragraph-color"
style="z-index: 100"
role="dialog"
aria-modal="true"
v-show="show || !decided"
>
<div class="flex gap-2 items-center">
<p class="font-semibold text-2xl">We use cookies</p>
<slot name="cookie-icon">
<Icon name="material-symbols:cookie" class="text-xl" />
</slot>
</div>
<p class="mt-2">
We use cookies and similar technologies to run the site, measure usage, and (with
your permission) personalize content and advertising.
</p>
<Transition name="slide-fade">
<!-- sliding panel -->
<div v-show="advanced" class="mt-4 border-t pt-4 overflow-hidden">
<!-- Analytics -->
<div class="mt-4">
<div class="flex items-center justify-between gap-4">
<label class="md:text-xl" for="analytics">Analytics</label>
<PublicMiscBaseSwitch
v-model="local.analytics"
aria-labelledby="analytics-label"
/>
</div>
<p class="text-sm md:text-base mt-2">
Measures page views, clicks and basic device info so we can debug, plan capacity and improve content. Reports are aggregated and not used to build advertising profiles.
</p>
</div>
<!-- Ads / Marketing -->
<div class="mt-4">
<div class="flex items-center justify-between gap-4">
<label class="md:text-xl" for="ads">Ads / Marketing</label>
<PublicMiscBaseSwitch
v-model="local.ads"
aria-labelledby="ads-label"
/>
</div>
<p class="text-sm md:text-base mt-2">
Enables advertising and conversion measurement. Ad partners may store identifiers to show or measure ads across sites.
</p>
</div>
<!-- Personalization -->
<div class="mt-4">
<div class="flex items-center justify-between gap-4">
<label class="md:text-xl" for="personalization">Personalization</label>
<PublicMiscBaseSwitch
v-model="local.personalization"
aria-labelledby="personalization-label"
/>
</div>
<p class="text-sm md:text-base mt-2">
Uses your interactions (e.g. pages viewed, settings) to adapt content, remember preferences beyond strict necessity and improve your experience. Turning this off means a more generic site.
</p>
</div>
<!-- Functional -->
<div class="mt-4">
<div class="flex items-center justify-between gap-4">
<label class="md:text-xl" for="functional">Functional (preferences)</label>
<PublicMiscBaseSwitch
v-model="local.functional"
aria-labelledby="functional-label"
/>
</div>
<p class="text-sm md:text-base mt-2">
Stores settings that make the site easier to use, such as remembering forms or accessibility options. Essential security and availability features remain on even if you disable this category.
</p>
</div>
<!-- Save -->
<div class="flex justify-end gap-4 mt-6">
<button class="cursor-pointer text-sm border px-4 py-2 rounded-iq-roundness hover:scale-110 transition" @click="onSave">Save</button>
</div>
</div>
</Transition>
<!-- Controll buttons -->
<div class="flex flex-wrap gap-2 items-center mt-6">
<button
class="cursor-pointer text-sm px-4 py-2 hover:scale-110 transition bg-iq-primary border-iq-primary rounded-iq-roundness text-iq-paragraph-secondary-color"
@click="
acceptAll();
show = false;
"
>
Accept
</button>
<button
class="cursor-pointer text-sm border px-4 py-2 rounded-iq-roundness hover:scale-110 transition"
@click="
declineAll();
show = false;
"
>
Reject
</button>
<button
class="text-sm cursor-pointer select-none px-4 py-2 border inline-flex items-center gap-2 rounded-iq-roundness hover:scale-110 transition"
@click="advanced = !advanced"
>
<span>Customize</span>
<Icon name="material-symbols:settings" class="" />
</button>
</div>
</div>
</template>
<style scoped>
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 540ms ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
opacity: 0;
max-height: 0; /* collapsed */
}
.slide-fade-enter-to,
.slide-fade-leave-from {
opacity: 1;
max-height: 1000px; /* big enough to fit content */
}
</style>
// ~/composables/useConsent.js
export function useConsent() {
const { public: pub } = useRuntimeConfig()
const prefix = (pub && (pub.consentPrefix || pub.consent_prefix)) || 'default_cookie_prefix'
const isProd = globalThis._importMeta_.env.PROD
// Decision flag: has the user chosen?
const decided = useCookie(prefix + '_decided', {
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 60 * 60 * 24 * 365,
default: function () { return false }
})
// Normalized consent categories
const consent = useCookie(prefix + '_consent', {
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 60 * 60 * 24 * 365,
default: function () {
return {
// essential is implied true (not user-toggleable)
analytics: 'denied',
ads: 'denied',
personalization: 'denied',
functional: 'denied',
ts: Date.now(),
version: 1
}
}
})
// Domain actions (no provider logic here)
const ALLOWED_KEYS = ['analytics', 'ads', 'personalization', 'functional']
function normalizeValue(v) {
if (v === true) return 'granted'
if (v === false) return 'denied'
if (typeof v === 'string') {
const s = v.trim().toLowerCase()
if (s === 'granted' || s === 'grant') return 'granted'
if (s === 'denied' || s === 'deny') return 'denied'
}
return null // invalid
}
function sanitizePatch(patch) {
const out = {}
const invalid = { keys: [], values: [] }
for (const key of Object.keys(patch || {})) {
if (!ALLOWED_KEYS.includes(key)) {
invalid.keys.push(key)
continue
}
const normalized = normalizeValue(patch[key])
if (!normalized) {
invalid.values.push([key, patch[key]])
continue
}
out[key] = normalized
}
if (!globalThis._importMeta_.env.PROD && (invalid.keys.length || invalid.values.length)) {
// Dev-only diagnostics
// eslint-disable-next-line no-console
console.warn('[consent] ignored fields', invalid)
}
return out
}
function shallowEqual(a, b) {
for (const k of ALLOWED_KEYS) {
if ((a && a[k]) !== (b && b[k])) return false
}
return true
}
// Domain actions (no provider logic here)
function setConsent(patch) {
const clean = sanitizePatch(patch)
if (Object.keys(clean).length === 0) return // nothing valid to set
const next = { ...consent.value, ...clean }
if (shallowEqual(next, consent.value)) return // no-op; don't bump ts
consent.value = { ...next, ts: Date.now() } // bump ts only on actual change
decided.value = true; // mark as decided
}
function setAll(state) {
setConsent({
analytics: state, ads: state, personalization: state, functional: state
})
}
function acceptAll() { setAll('granted'); decided.value = true }
function declineAll() { setAll('denied'); decided.value = true }
function decide() { decided.value = true }
function resetDecision() { decided.value = false }
return {
// state
consent,
decided,
// actions
setConsent,
acceptAll,
declineAll,
decide,
resetDecision
}
}
// plugins/ga.client.js
export default defineNuxtPlugin(() => {
const { consent } = useConsent()
// 1) setup proxy
const { proxy, onLoaded } = useScriptGoogleAnalytics()
// 3) map consent -> GA fields
const toGA = (c) => ({
analytics_storage: c.analytics, // 'granted' | 'denied'
ad_storage: c.ads,
ad_personalization: c.personalization,
ad_user_data: c.ads // simple policy: follow "ads"
})
// 2/4) when GA ready: send default, then current; keep watching
onLoaded(() => {
proxy.gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_personalization: 'denied',
ad_user_data: 'denied'
})
proxy.gtag('consent', 'update', toGA(consent.value))
})
watch(() => ({ ...consent.value }), (c) => {
if (proxy?.gtag) {
proxy.gtag('consent', 'update', toGA(c))
}
}, { deep: true })
})
This is the main Vue file that uses the component. Copy-paste this into your project. In this code feel free to change anything you like, such as the component name, props, or class. This is the place where you control the main component.
<script setup>
</script>
<template>
<div class="w-6xl flex items-center justify-center h-full">
<CoreConsent ref="consentRef" class="iq-card-glass" >
</CoreConsent>
<!-- Example footer/link trigger -->
<button class="text-sm p-4 text-white border rounded hover:scale-110" @click="$refs.consentRef.toggleConsentManagement">
Example manage consent button
</button>
</div>
</template>
Decide whether you want a global design-system or a one-off inline snippet.
Complete @theme
block – import once and share across every component.
Copy the code below into main.css
file. It is most likely in assets/css/main.css
directory.
:style
binding – paste straight onto any of ours components.
Copy the code below and paste it into the :style
binding of the component.
I wish I could automate every little thing—but for now you’ll need to handle these final steps by hand. Apologies for the extra work!
iq-card-*
style Now that you’ve picked a card preset, copy its CSS into your @layer components
block in main.css
. This ensures every `iq-card
` wrapper will look just right.
iq-cta
iq-cta is the main call to action button class. It’s used in many places across the components. But for now it is only a single class that you can customize. You can copy the code below and paste it into your @layer components
block in main.css
. In future you will be able to fully customize it from our UI and choose from many presets.
I didn't have time to figure out consistency. Although there are no actions required, be mindful that the forms might not be entirely consistent with the design system. A quick once-over will keep everything looking sharp.