@zeste/vue
zeste
Headless cookie-consent management for the web: it keeps the service catalog of tarteaucitron.js (MIT) and replaces its engine and its UI.
Website and documentation · Documentation en français
| Package | Role |
|---|---|
@zeste/core |
Framework-free engine: state, cookie (tarteaucitron format), plugins (Google Consent Mode v2), tarteaucitron shim |
@zeste/services |
The tarteaucitron catalog split up: one module per service, synchronous metadata, code loaded on demand |
@zeste/svelte |
Svelte 5: reactive state (runes), <ZesteProvider>, <ConsentGate>, draft preferences, SSR |
@zeste/react |
React 18/19: useZeste() hook, <ZesteProvider>, <ConsentGate>, draft preferences, SSR (Next.js App Router) |
@zeste/vue |
Vue 3: reactive state, v-model on the preferences and the draft, <ZesteProvider>, <ConsentGate>, SSR (Nuxt) |
@zeste/browser |
zeste as a <script> tag for sites without a JavaScript framework (WordPress, static sites): ready-made banner, drop-in replacement for tarteaucitron.js |
zeste (CLI) |
npx zeste add: banner, preferences and placeholder copied into your project, to edit. Ready-made for Bits UI, Ark UI, shadcn-svelte (Svelte), Radix, Ark UI, shadcn/ui (React) and Reka UI, Ark UI, shadcn-vue (Vue); with any other UI, start from one and swap the imports |
examples/vanilla |
Demo without a framework |
examples/sveltekit |
SvelteKit 3 demo (SSR, adapter-node) in plain HTML, no UI library |
examples/nextjs |
The same demo with Next.js 16 (App Router, SSR) |
examples/nuxt |
The same demo with Nuxt 4 (SSR) |
examples/static-site |
Plain HTML pages with the <script> version, including one that kept its tarteaucitron configuration |
examples/svelte-spa, react-spa, vue-spa |
The same demo without a server framework (Vite, client-only); React 18 for the React one |
examples/skin-bits, skin-ark, skin-shadcn |
The same demos with Tailwind, using components generated by zeste add |
examples/skin-react-radix, skin-react-ark, skin-react-shadcn |
The same, with React and Next.js |
examples/skin-vue-reka, skin-vue-ark, skin-vue-shadcn |
The same, with Vue and Nuxt |
e2e |
Chromium tests (Playwright): the whole catalog with the network intercepted + real services on the real network |
site |
The website and its documentation (Astro + Starlight, English and French) |
pnpm install
pnpm test # unit tests
pnpm --filter @zeste/services generate # regenerates the catalog from tarteaucitronjs
pnpm --filter example-vanilla dev
pnpm --filter e2e test # browser tests (first time: pnpm --filter e2e exec playwright install chromium)
Without a framework: one <script> tag
For a site without a JavaScript framework (WordPress theme, static site, PHP templates), @zeste/browser
brings the engine, the catalog and a ready-made banner in both styles, in the page's language:
<script>
window.zesteConfig = {
services: { gtag: { gtagUa: 'G-XXXXXXX' }, youtube: {} },
privacyUrl: '/confidentialite',
style: 'zeste', // or 'pelure'
};
</script>
<script src="/zeste/zeste.js"></script>
<!-- anywhere: a link to change one's mind (a round button is also shown once the choice is made) -->
<a href="#zeste">Gestion des cookies</a>
It is also a drop-in replacement for tarteaucitron.js: replace its <script> tag with zeste's, at the same place
in <head>, and keep tarteaucitron.init({…}), tarteaucitron.user.* and tarteaucitron.job as they are.
Visitors keep their choices (same cookie, custom cookieName included), the #tarteaucitron links still open
the preferences, and the <div class="youtube_player"> markup still works. Only the declared services and the
page language are downloaded (the engine and the interface weigh 17 kB gzipped). window.zeste exposes
open(), acceptAll(), denyAll(), has(key)… for the page's own scripts.
Getting started: npx zeste init
npx zeste init # writes lib/consent.ts (in src/, Nuxt's app/ or the root)
npx zeste init --dry-run # report only
Without a package.json (a site without npm), it writes zeste.config.js for the <script> version instead
(--format module|script to choose).
zeste init reads the project once and declares the services it already uses: a tarteaucitron configuration
(tarteaucitron.job, tarteaucitron.user.*, cookie name and domain, Consent Mode), and the most common snippets
pasted as is in pages (Google Analytics, Google Ads, Tag Manager, Matomo, Hotjar, Meta Pixel, Clarity, Google Maps,
YouTube and Vimeo embeds). Its report lists, file and line, the scripts to remove (zeste loads them after consent),
the embeds to put behind <ConsentGate>, and the options left to fill in. It does not try to recognize every third
party: check the list, then add the missing services from the catalog.
Usage with SvelteKit
// +layout.server.ts — the choice is read on the server: no banner flash
export const load = ({ cookies }) => ({ consent: parseConsentCookie(cookies.get('tarteaucitron')) });
<!-- +layout.svelte: one instance per request, never at module level -->
<script lang="ts">
import { createZeste, ZesteProvider } from '@zeste/svelte';
import { gtag, youtube } from '@zeste/services';
import { messages } from '@zeste/services/i18n/fr'; // tarteaucitron texts, 36 languages
let { data, children } = $props();
const zeste = createZeste({ services: [gtag({ gtagUa: 'G-XXX' }), youtube()], initial: data.consent, messages });
</script>
<ZesteProvider {zeste}>
{@render children()}
{#if zeste.showBanner}
<MyBanner text={zeste.messages.alertBigPrivacy} onaccept={zeste.acceptAll} ondeny={zeste.denyAll} />
{/if}
</ZesteProvider>
<ConsentGate service="youtube" embed={{ videoID: 'dQw4w9WgXcQ' }}>
{#snippet placeholder({ service, allow })}
<MyPlaceholder name={service.name} onclick={allow} />
{/snippet}
</ConsentGate>
Without messages, the texts default to English. In a child component, getZeste<Messages>() (the Messages type
from @zeste/services/i18n/types) gives access to the typed texts: zeste.messages.acceptAll,
zeste.messages.categories.analytic?.title…
Usage with Next.js (React)
// app/layout.tsx (server component): the choice is read on the server, no banner flash
const consent = parseConsentCookie((await cookies()).get('tarteaucitron')?.value);
return <html><body><Consent initial={consent}>{children}</Consent></body></html>;
// app/Consent.tsx
'use client';
export function Consent({ initial, children }) {
// one instance per request on the server, one per page in the browser: never at module level
const [zeste] = useState(() => createZeste({ services: [gtag({ gtagUa: 'G-XXX' }), youtube()], initial, messages }));
return (
<ZesteProvider zeste={zeste}>
{children}
<ConsentBanner>
<p>{zeste.messages.alertBigPrivacy}</p>
<Button {...zeste.actions.denyAll}>{zeste.messages.denyAll}</Button>
<Button {...zeste.actions.acceptAll}>{zeste.messages.acceptAll}</Button>
</ConsentBanner>
</ZesteProvider>
);
}
<ConsentGate
service="youtube"
embed={{ videoID: 'dQw4w9WgXcQ' }}
placeholder={({ service, allow }) => <MyPlaceholder name={service.name} onClick={allow} />}
/>
In a child component, useZeste<Messages>() returns the same object as in Svelte and re-renders on every change.
The differences: actions are props to spread ({...zeste.actions.denyAll}), the draft is edited with
zeste.setDraft(key, checked), and a controlled dialog uses open={zeste.preferencesOpen} onOpenChange={zeste.setPreferencesOpen}.
Usage with Nuxt (Vue)
<!-- app/app.vue: useCookie reads the choice on the server too, no banner flash -->
<script setup lang="ts">
import { createZeste, parseConsentCookie, ZesteProvider, ConsentBanner } from '@zeste/vue';
const cookie = useCookie('tarteaucitron', { decode: (value) => value });
// one instance per request on the server, one per page in the browser: never at module level
const zeste = createZeste({ services: [gtag({ gtagUa: 'G-XXX' }), youtube()], initial: parseConsentCookie(cookie.value), messages });
</script>
<template>
<ZesteProvider :zeste="zeste">
<NuxtPage />
<ConsentBanner>
<p>{{ zeste.messages.alertBigPrivacy }}</p>
<Button v-bind="zeste.actions.denyAll">{{ zeste.messages.denyAll }}</Button>
<Button v-bind="zeste.actions.acceptAll">{{ zeste.messages.acceptAll }}</Button>
</ConsentBanner>
</ZesteProvider>
</template>
<ConsentGate service="youtube" :embed="{ videoID: 'dQw4w9WgXcQ' }">
<template #placeholder="{ service, allow }"><MyPlaceholder :name="service.name" @click="allow" /></template>
</ConsentGate>
In a child component, useZeste<Messages>() returns the same object, reactive. Actions bind with
v-bind="zeste.actions.denyAll", a dialog with v-model:open="zeste.preferencesOpen" and a switch with
v-model="zeste.draft[key]".
Language
The texts exist in the 36 tarteaucitron languages. @zeste/services/i18n picks one and loads only that one:
import { acceptedLanguages, detectLanguage, loadMessages, pickLanguage } from '@zeste/services/i18n';
// Browser only (no server rendering): the page language (<html lang>), then the browser's, English otherwise
const messages = await loadMessages(detectLanguage());
// With server rendering, choose on the server and pass the texts to the page, like the consent cookie
// (SvelteKit `load`, Next.js server component props, Nuxt `useAsyncData`):
const language = pickLanguage([pageLanguage, ...acceptedLanguages(request.headers.get('accept-language'))]);
const messages = await loadMessages(language);
A site in a single language can keep importing it directly: import { messages } from '@zeste/services/i18n/fr'.
With your UI library: npx zeste add
zeste is headless: it works with any UI, including your own components (see the end of this section).
zeste add is a shortcut that writes a first version with the libraries below, Tailwind classes included.
npx zeste add # detects the framework, then shadcn (components.json), Ark UI, Bits UI / Radix / Reka UI
npx zeste add --ui ark # or explicitly: bits | ark | shadcn (Svelte), radix | ark | shadcn (React), reka | ark | shadcn (Vue)
npx zeste add --style pelure # two styles: zeste (default, understated, lemon accent) | pelure (flat, thick outlines)
Colors live in theme.css (--zeste-* variables, scoped to the skin's elements): changing
--zeste-ink and --zeste-accent is often enough to match a site's colors. Dark mode via the dark class
or data-theme="dark". The shadcn variants have no theme.css: they follow the project's theme. The shadcn/ui
(React) variant works with both of its flavors, Base UI (the default) and Radix.
This copies a banner, preferences and a placeholder into src/lib/components/zeste/ (Svelte) or
components/zeste/ in src/, Nuxt's app/ or the project root (React, Vue), written with your
library's components (your shadcn components if there are any). Those files are yours: edit them freely,
zeste add never overwrites them without --force. The logic, the accessibility and the compliance checks stay
in @zeste/svelte, @zeste/react or @zeste/vue and are updated through npm.
With a custom design system, start from the closest variant and replace the imports: you only need to attach the zeste actions to your own buttons.
<ConsentBanner class="…"> <!-- rendered only if the visitor has not chosen yet -->
<MyButton {@attach zeste.actions.denyAll}>{t.denyAll}</MyButton>
<MyButton {@attach zeste.actions.acceptAll}>{t.acceptAll}</MyButton>
</ConsentBanner>
<MyDialog bind:open={zeste.preferencesOpen}>
<MySwitch bind:checked={zeste.draft.youtube} />
</MyDialog>
<ConsentBanner className="…">
<MyButton {...zeste.actions.denyAll}>{t.denyAll}</MyButton>
<MyButton {...zeste.actions.acceptAll}>{t.acceptAll}</MyButton>
</ConsentBanner>
<MyDialog open={zeste.preferencesOpen} onOpenChange={zeste.setPreferencesOpen}>
<MySwitch checked={zeste.draft.youtube} onCheckedChange={(on) => zeste.setDraft('youtube', on)} />
</MyDialog>
Proof of consent
The GDPR (art. 7) requires being able to show that a visitor consented. onRecord is called after every choice
with what proves it; sending and keeping it is up to the site:
createZeste({
services,
policyVersion: '2026-10',
onRecord: (record) => navigator.sendBeacon('/consent', JSON.stringify(record)),
});
// { id: '9f2c…', date: '2026-10-09T14:03:12.000Z', action: 'denyAll',
// choices: { gtag: false, youtube: false }, policyVersion: '2026-10' }
id is random and kept in the consent cookie: it identifies the choices of a browser across changes, not a
person. action says how the choice was made (acceptAll, denyAll, set from the preferences, grant from a
placeholder…). Reading a stored choice back is not a new choice and is not reported. The same option exists in
window.zesteConfig for the <script> version.
Compliance guard (dev mode)
Consent rules across the EU (ePrivacy Directive art. 5(3), GDPR, and the EDPB guidance on cookie banners) boil down to a few checkable points: refusing must be as easy as accepting, nothing non-essential runs before consent, and withdrawing consent must stay possible at any time. Regulators such as the French CNIL enforce them actively.
The guard is on by default in development (guard: true to force it). It observes the actual rendering, whatever
the design system, and warns in the console if:
- the banner has no "Deny all" button at the first level, or it is invisible;
- "Deny all" is clearly smaller or less contrasted than "Accept all" (any CSS color, including Tailwind v4's
oklch); - nothing lets the visitor reopen the preferences once the choice is made.
It finds the buttons through {@attach zeste.actions.*}: a plain onclick={zeste.denyAll} works,
but is not checked by the guard.
Tested guarantees
- No request to a third party before consent, verified at the Chromium network level for all 247 services.
The "cookieless" modes of tarteaucitron that contact a third party before consent (Matomo Cloud, GetQuanty,
advanced Consent Mode…) are disabled by default: opt in with
allowPreConsentRequests. - Every accepted service produces a real effect (script, iframe, pixel, tag queue).
- Exempt services (
needsConsent: false, e.g. self-hosted Matomo) are loaded straight away. - SvelteKit: banner and placeholders are present in the server HTML; a visitor who has already decided never sees the banner (not even for a frame); preferences have no pre-checked box; mounting a new embed does not reload the others; client-side navigation; consent withdrawal.
- Bits UI, Ark UI and shadcn-svelte skins: the same tests for all three (axe accessibility with no serious violation, keyboard, focus, switches announced as such, a guard with no false positive that detects a shrunken or low-contrast "reject" button — hex or oklch — or the lack of a link to change one's mind).
- On the real network (GA4, YouTube, Matomo Cloud):
_gacookies set only after acceptance and removed on withdrawal; YouTube iframe rendered / removed without a reload; Matomo hit sent; choice remembered.
License
MIT. The service catalog and its translations come from tarteaucitron.js (MIT, Copyright (c) 2014 AmauriC): see THIRD_PARTY_NOTICES.md.
Related Packages
Vue 3 and Nuxt bindings for Plain Theory consent: plugin, useConsent composable...
Centralized cookie consent management (CMP) for 30+ sites — a typed wrapper arou...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.1.0 | ^3.4.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |