A lightweight cookie consent toolkit for GDPR/CCPA compliance.
- Lightweight — ~14KB gzipped (single language) / ~19KB (all 12 languages) / ~14KB (headless)
- Zero dependencies — Vanilla JavaScript
- Shadow DOM — Styles isolated from your site
- Headless mode — Bring your own UI & CSS, use only the consent engine
- Privacy-first — Respects Do Not Track / Global Privacy Control
- Geo-aware — Optional jurisdiction gating: full GDPR banner in the EU, a "Do Not Sell" link in the US, nothing elsewhere — via the built-in zest-geo gateway or your own resolver
- Security-hardened — XSS-safe templating, URL/color/regex validation, locked interceptors
<!-- unpkg -->
<script src="https://unpkg.com/@freshjuice/zest"></script>
<!-- jsdelivr -->
<script src="https://cdn.jsdelivr.net/npm/@freshjuice/zest"></script>With configuration:
<script>
window.ZestConfig = {
position: 'bottom-right',
theme: 'auto',
accentColor: '#0071e3',
policyUrl: '/privacy-policy'
};
</script>
<script src="https://unpkg.com/@freshjuice/zest"></script>As an npm dependency:
import Zest from '@freshjuice/zest';
Zest.init({ mode: 'safe', policyUrl: '/privacy' });| Entry | What you get | Min / Gzip |
|---|---|---|
@freshjuice/zest |
Consent engine + Shadow DOM UI (banner, modal, widget) | ~62 KB / ~19 KB |
@freshjuice/zest/headless |
Consent engine only, no UI / no CSS — you build the UI | ~40 KB / ~14 KB |
Use headless when you want full control over markup and styling.
Official plugins inject the Zest IIFE inline into <head> at build time, so
interceptors install before any other script — with no extra HTTP request. Pass
runtime config (including geo: true) straight through.
| Package | Framework | Docs |
|---|---|---|
@freshjuice/zest-astro |
Astro 3 / 4 / 5 / 6 | README |
@freshjuice/zest-eleventy |
Eleventy (11ty) 2+ | README |
// astro.config.mjs
import zest from '@freshjuice/zest-astro';
export default defineConfig({
integrations: [
zest({ language: 'en', config: { theme: 'auto', geo: true, policyUrl: '/privacy' } })
]
});Config is serialised into an inline
window.ZestConfig, so serialisable geo forms (geo: true,provider,endpoint,timeout,fallback) work. The function forms (resolver/decide) can't be serialised — use those in client-side JS instead.
window.ZestConfig = {
// Position: 'bottom' | 'bottom-left' | 'bottom-right' | 'top'
position: 'bottom',
// Theme: 'light' | 'dark' | 'auto' (default: 'auto' follows system)
theme: 'auto',
// Accent color — must be a valid CSS color (hex, named, rgb/rgba, hsl/hsla)
accentColor: '#0071e3',
// Link to privacy policy — only http:/https:/mailto:/tel:/relative allowed
policyUrl: '/privacy',
// Show floating widget after consent
showWidget: true,
// Show the "Powered by Zest" link on the banner & modal (default: true)
branding: true,
// Consent expiration in days
expiration: 365,
// Geo gating — off by default. `true` uses the hosted gateway; pass an
// object for a custom endpoint / resolver / decide(). See the
// "Geolocation / jurisdiction gating" section.
geo: false,
// Callbacks — wrapped in try/catch internally, safe to throw
callbacks: {
onAccept: (consent) => {},
onReject: () => {},
onChange: (consent) => {},
onReady: (consent) => {},
onGeo: (action, verdict) => {} // fires when `geo` is configured
}
};<script
src="zest.min.js"
data-position="bottom-left"
data-theme="dark"
data-accent="#0071e3"
data-policy-url="/privacy"
data-geo="on"
data-branding="false"
></script>
data-geo="on"enables the hosted gateway. Theresolver/decidecallbacks are JavaScript-only — usewindow.ZestConfig.geofor those.
// Show/hide UI (full build only)
Zest.show() // Show banner
Zest.hide() // Hide banner
Zest.showSettings() // Show settings modal
Zest.hideSettings() // Close settings modal
Zest.reset() // Clear consent + reshow banner
// Consent state
Zest.getConsent() // { essential, functional, analytics, marketing }
Zest.hasConsent('analytics') // boolean
Zest.hasConsentDecision() // boolean — has the user made a choice yet?
Zest.getConsentProof() // full consent cookie payload (compliance audit)
// Programmatic actions
Zest.acceptAll()
Zest.rejectAll()
Zest.updateConsent({ analytics: true, marketing: false }) // headless only
// DNT / GPC
Zest.isDoNotTrackEnabled()
Zest.getDNTDetails() // { enabled, source: 'dnt'|'gpc'|null }
// Geo / jurisdiction (when `geo` is configured — see below)
Zest.resolveGeo() // headless: await { action, verdict }
// Events — subscribe helpers (also work with addEventListener)
Zest.on('zest:change', (e) => {})
Zest.once('zest:ready', (e) => {})
Zest.EVENTS // { READY, CONSENT, REJECT, CHANGE, SHOW, HIDE, GEO }Full control over markup and styling, no Shadow DOM, no inline CSS.
import Zest from '@freshjuice/zest/headless';
Zest.init({
mode: 'safe',
respectDNT: true,
consentModeGoogle: true
});
// Decide when to show YOUR banner
if (!Zest.hasConsentDecision()) {
document.querySelector('#my-banner').classList.add('open');
}
// Wire your buttons
document.querySelector('#accept').onclick = () => Zest.acceptAll();
document.querySelector('#reject').onclick = () => Zest.rejectAll();
document.querySelector('#save').onclick = () => {
Zest.updateConsent({
analytics: analyticsCheckbox.checked,
marketing: marketingCheckbox.checked,
functional: functionalCheckbox.checked
});
};
// Listen for changes
Zest.on(Zest.EVENTS.CHANGE, (e) => {
console.log('consent changed', e.detail.consent);
});What headless gives you:
- All interceptors (cookies, storage, scripts) still work — just skip the built-in UI
- Same config surface (
mode,respectDNT,consentModeGoogle,blockedDomains,patterns, etc.) - Does NOT auto-init — you call
Zest.init()when ready - Does NOT set
window.Zest— you import and use the module directly
See examples/headless.html for a complete working example.
Zest respects browser privacy signals by default:
window.ZestConfig = {
respectDNT: true, // Respect DNT/GPC signals (default: true)
dntBehavior: 'reject' // What to do when DNT is enabled
};| Behavior | Description |
|---|---|
reject |
Auto-reject all non-essential cookies, don't show banner (default) |
preselect |
Show banner with non-essential options unchecked |
ignore |
Ignore DNT/GPC signals completely |
Zest.isDoNotTrackEnabled() // true if DNT or GPC is enabled
Zest.getDNTDetails() // { enabled: boolean, source: 'dnt' | 'gpc' | null }By default Zest shows the banner to everyone. Opt into geo gating and it resolves the visitor's location and decides which experience to present:
window.ZestConfig = { geo: true }; // that's itDefault behaviour once enabled, matching the legal model:
| Jurisdiction | Action | What the visitor sees |
|---|---|---|
| GDPR / EEA / UK | consent |
Opt-in — full banner, tracking blocked until they choose |
| US state-privacy (CCPA, CPRA, VCDPA…) | notice |
Opt-out — tracking runs, a small "Do Not Sell or Share My Personal Information" link |
| Everywhere else | allow |
Nothing — tracking allowed, no UI |
geo: true is shorthand for { provider: 'gateway' }, which uses the hosted
zest-geo gateway — a Cloudflare Worker that
reads the edge geo of the request and returns the applicable privacy regimes.
It stores nothing, logs nothing, and the /privacy endpoint carries no IP /
city / coordinates. Zero infrastructure on your side.
window.ZestConfig = {
geo: {
// Pick ONE source:
provider: 'gateway', // hosted zest-geo (default)
// endpoint: 'https://geo.example.com/privacy', // your self-hosted zest-geo
// resolver: async () => ({ isGDPR, isUSPrivacy, isCCPA, isEU, isEEA, regulations }),
// Optional — map the verdict to an action (this is the default):
decide: (geo) =>
geo.isGDPR ? 'consent' : geo.isUSPrivacy ? 'notice' : 'allow',
timeout: 1500, // ms before giving up
fallback: 'consent' // action if resolution fails/times out (fail-closed)
}
};The resolver option is the recommended path if your CDN already knows the
country — read its geo header (CF-IPCountry, x-vercel-ip-country, etc.) and
return the verdict yourself, no extra request. Whatever the source, it must
return the gateway's shape:
{ isEU, isEEA, isGDPR, isCCPA, isUSPrivacy: boolean, regulations: string[] }decide() must return one of four actions:
| Action | Interceptors | UI |
|---|---|---|
'consent' |
stay blocked until decision | full banner / modal |
'notice' |
allow + replay queue (opt-out) | "Do Not Sell" link |
'allow' |
allow + replay queue | nothing |
'block' |
stay blocked | nothing (fail-closed) |
Anything outside this set is clamped to fallback.
Interceptors install synchronously on script eval, so trackers stay blocked
no matter what. Geo resolves asynchronously — Zest holds the UI until the
verdict lands, then mounts the right experience. On timeout or error it fails
closed to fallback (default 'consent').
Trade-off: in allow / notice regions, trackers are held for the brief
gateway round-trip (~tens of ms) before being released. That's correct
fail-closed behaviour — nothing leaks while the verdict is in flight.
Headless renders no UI, so the result reaches you via the onGeo callback, the
zest:geo event, or await Zest.resolveGeo(). Tracking is already accepted for
notice / allow by the time it fires:
import Zest from '@freshjuice/zest/headless';
Zest.init({
geo: true,
callbacks: {
onGeo: (action, verdict) => {
if (action === 'consent') myBanner.show(); // GDPR — opt-in
if (action === 'notice') myDoNotSellLink.show(); // US — opt-out
// 'allow' / 'block' — render nothing
}
}
});
// …or await it directly:
const { action, verdict } = await Zest.resolveGeo();When
geois set, theinit()snapshot returnsgeoPending: trueuntil the verdict resolves. Branch on that rather than readinghasConsentDecision()immediately — it's stillfalsewhile resolution is in flight.
Control how aggressively scripts are blocked:
window.ZestConfig = {
mode: 'safe' // 'manual' | 'safe' | 'strict' | 'doomsday'
};| Mode | Description |
|---|---|
manual |
Only blocks scripts with data-consent-category attribute |
safe |
Manual + known major trackers (Google Analytics, Facebook, etc.) |
strict |
Safe + extended tracker list (Hotjar, Mixpanel, Segment, etc.) |
doomsday |
Block ALL third-party scripts |
window.ZestConfig = {
mode: 'safe',
blockedDomains: [
'custom-tracker.com',
{ domain: 'another-tracker.com', category: 'analytics' }
]
};<script data-consent-category="analytics" src="https://..."></script>
<script data-consent-category="marketing">
// Inline scripts also supported
</script>Note:
data-consent-category="essential"on third-party scripts is ignored — self-labeling as essential is a known bypass. Onlyfunctional,analytics, andmarketingself-labels are honored.
<script data-zest-allow src="https://cdn.example.com/library.js"></script>document.addEventListener('zest:consent', (e) => {
console.log('User accepted:', e.detail.consent);
});
document.addEventListener('zest:reject', (e) => {
console.log('User rejected');
});
document.addEventListener('zest:change', (e) => {
console.log('Consent changed:', e.detail);
});
document.addEventListener('zest:ready', (e) => {
console.log('Zest initialized:', e.detail.consent);
});
// Fires only when `geo` is configured — see "Geolocation / jurisdiction gating"
document.addEventListener('zest:geo', (e) => {
console.log('jurisdiction resolved:', e.detail.action, e.detail.verdict);
});
// Or via the helpers
Zest.on(Zest.EVENTS.CHANGE, (e) => { /* ... */ });
Zest.once(Zest.EVENTS.READY, (e) => { /* ... */ });Optional — push consent state to Google and Microsoft advertising APIs.
window.ZestConfig = {
consentModeGoogle: true,
consentModeMicrosoft: true
};<script
src="zest.min.js"
data-consent-mode-google="true"
data-consent-mode-microsoft="true"
></script>When enabled, Zest automatically:
- Pushes a
'default'denied state on page load (before any tracking scripts fire) - Pushes an
'update'whenever the user makes a choice
| Zest Category | Google Consent Mode v2 Signals | Microsoft UET Signal |
|---|---|---|
essential |
functionality_storage: 'granted' (always) |
— |
functional |
personalization_storage |
— |
analytics |
analytics_storage |
— |
marketing |
ad_storage, ad_user_data, ad_personalization |
ad_storage |
Built-in translations with auto-detection.
Supported languages: en, de, es, fr, it, pt, nl, pl, uk, ru, ja, zh
| Bundle | Size (gzip) | Description |
|---|---|---|
zest.min.js |
~19 KB | All 12 languages, auto-detects |
zest.{lang}.min.js |
~14 KB | Single language (e.g. zest.de.min.js) |
zest.headless.esm.min.js |
~14 KB | Logic only, no UI / no translations (ESM import) |
<!-- Full bundle - auto-detects language -->
<script src="https://unpkg.com/@freshjuice/zest"></script>
<!-- Single language bundle - smaller size -->
<script src="https://unpkg.com/@freshjuice/zest/dist/zest.de.min.js"></script>window.ZestConfig = { lang: 'auto' }; // defaultPriority: lang config → <html lang="..."> → navigator.language → English.
window.ZestConfig = { lang: 'de' };window.ZestConfig = {
lang: 'de',
labels: {
banner: {
title: 'Custom German Title'
}
}
};Standalone JSON translation files are in /locales/.
The UI is rendered inside a Shadow DOM with mode: 'open', so your global
CSS can't reach inside the component. You have three options:
The following custom properties are exposed on the host elements:
zest-banner, zest-modal, zest-widget {
--zest-accent: #0071e3;
--zest-bg: #ffffff;
--zest-bg-secondary: #f3f4f6;
--zest-text: #1f2937;
--zest-text-secondary: #6b7280;
--zest-border: #e5e7eb;
--zest-radius: 12px;
--zest-radius-sm: 8px;
}window.ZestConfig = {
customStyles: `
.zest-banner { max-width: 600px; }
.zest-btn--primary { border-radius: 20px; }
.zest-modal { max-width: 600px; }
`
};Security note:
customStylesis sanitized —@import,expression(), externalurl()values, and selectors targeting the accept/reject buttons are stripped. This prevents clickjacking via invisible-button CSS attacks. Payloads over 20 KB are dropped entirely.
The custom elements zest-banner, zest-modal, zest-widget live in the
light DOM — you can position, hide, or z-index them from your global CSS.
Use the headless entry and style your own markup however you like.
By default a small "Powered by Zest" link (→ cookiezest.com) appears on the banner and the settings modal. Turn it off with one flag:
window.ZestConfig = { branding: false };<script src="zest.min.js" data-branding="false"></script>Headless builds render no UI, so this option doesn't apply there.
| Category | ID | Default | Description |
|---|---|---|---|
| Essential | essential |
ON | Required cookies (cannot be disabled) |
| Functional | functional |
OFF | Personalization features |
| Analytics | analytics |
OFF | Usage tracking |
| Marketing | marketing |
OFF | Advertising cookies |
Unknown cookies default to marketing (strictest).
Zest takes a defense-in-depth approach to security.
Highlights:
- All config-driven HTML is escaped via an internal
escapeHTMLpass policyUrlis validated against an allowlist (http:,https:,mailto:,tel:, relative)accentColormust pass a strict color validatorcustomStylesis sanitized (see above)- Consent cookie JSON is schema-validated on read (prototype pollution safe)
- On HTTPS, the consent cookie is written with the
Secureflag window.Zestis frozen and non-configurable once installed- User callbacks are wrapped in try/catch so a throwing handler can't break the consent flow
- Cookie / storage / script queues are size-capped (DoS prevention)
To report a vulnerability, open a private security advisory on GitHub.
JSON Schema for IDE autocompletion: zest.config.schema.json
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes
- Push to the branch
- Open a Pull Request
Built by Alex Zappa at FreshJuice