Contents
CookieDialog is a lightweight, zero-dependency cookie consent dialog for GDPR compliance. It ships as a single script plus a small stylesheet (about 6 KB gzipped together), works from a CDN tag or an npm import, and includes optional IP geolocation so visitors outside the EU/EEA never see the banner. It powers the consent dialog on this site.
- GitHub: github.com/timothydodd/cookiedialog
- npm: npmjs.com/package/cookiedialog
- License: MIT
- Current version: 1.1.0
Quick start
Add the stylesheet and script, then call init():
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/cookiedialog.min.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cookiedialog.min.js"></script>
<script>
CookieDialog.init({
privacyUrl: '/privacy',
onAccept: (consent) => {
console.log('Consent given:', consent.categories);
}
});
</script>
That’s it — the dialog appears at the bottom of the page, remembers the visitor’s choice in localStorage, and skips itself on the next visit. CookieDialog.init() with no arguments works too; every option has a sensible default.
Installation
CDN
Pin a version (recommended for production):
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/cookiedialog.min.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cookiedialog.min.js"></script>
Use cookiedialog@1 to pick up patch and minor releases automatically, or cookiedialog@latest to always track the newest release.
npm
npm install cookiedialog
import CookieDialog from 'cookiedialog';
import 'cookiedialog/dist/cookiedialog.min.css';
CookieDialog.init({ privacyUrl: '/privacy' });
The default export is an API object with init(config), create(config), and the CookieDialog class itself. init builds the dialog and starts it immediately; create constructs an instance without initializing so you can call .init() later:
import { CookieDialog } from 'cookiedialog';
const dialog = new CookieDialog({ theme: 'auto', autoShow: false });
await dialog.init();
dialog.show();
ES modules from the CDN
<script type="module">
import CookieDialog from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/cookiedialog.esm.js';
CookieDialog.init();
</script>
Self-hosting
Download cookiedialog.min.js and cookiedialog.min.css from the GitHub releases page and serve them like any other static asset. This site does exactly that — no third-party CDN request required.
What the visitor sees
The dialog opens with a title, a short description, optional policy links, and three actions: Accept all, Reject all, and Manage preferences. Accept and Reject carry equal visual weight, which is what EU regulators now expect. Manage preferences swaps the action row for a panel listing each cookie category with a toggle, plus Save preferences, Accept all, and Reject all. Required categories show an “Always active” badge instead of a toggle.
Re-opening the dialog later (for example from a “Cookie settings” link in your footer) shows the visitor’s current choices in the toggles:
<a href="#" onclick="window.cookieDialog.show(); return false;">Cookie settings</a>
Accessibility is built in: the dialog has the proper dialog role and labelling, focus moves into it when it opens and returns when it closes, every toggle is labelled, Escape dismisses it, and the centered modal traps Tab. Animations are disabled for visitors with prefers-reduced-motion set.
Configuration
All options are optional. Defaults shown are the values applied when the option is omitted.
| Option | Type | Default | Description |
|---|---|---|---|
autoShow |
boolean |
true |
Show the dialog automatically during init(). Set false to call show() yourself. |
position |
string |
'bottom' |
'bottom', 'top', or 'center'. Center renders as a modal with a dimmed overlay; on phones it becomes a bottom sheet. |
theme |
string |
'light' |
'light', 'dark', or 'auto'. Auto follows the visitor’s prefers-color-scheme. |
privacyUrl |
string |
— | If set, a privacy policy link is rendered under the dialog text. |
cookiePolicyUrl |
string |
— | Same as privacyUrl, for a separate cookie policy page. |
expiryDays |
number |
365 |
How long stored consent is honored. Expired consent is cleared and the dialog shows again. |
forceShow |
boolean |
false |
Show the dialog even when valid consent is already stored (and even for non-EU visitors when geolocation is on). |
debug |
boolean |
false |
Log geolocation and lifecycle details to the console. Nothing is logged otherwise. |
enableLocation |
boolean |
false |
Look up the visitor’s country by IP and skip the dialog outside the EU/EEA. See Geolocation. |
geolocationEndpoint |
string |
'https://ipapi.co/json/' |
Custom geolocation API URL. See Custom endpoints for the required response shape. |
categories |
array |
3 built-ins | Cookie categories shown in the preferences panel. See Categories. |
translations |
object |
English strings | Any user-facing text you want to override. See Translations. |
onAccept |
function |
— | Called with the consent state when the visitor accepts all, saves preferences with at least one optional category enabled, when valid stored consent is found on load, and after a location-based auto-accept. |
onReject |
function |
— | Called with the consent state when the visitor rejects all, or saves preferences with every optional category disabled. |
onChange |
function |
— | Called with the consent state whenever the visitor saves from the preferences panel. Fires alongside onAccept or onReject. |
onLocationNotRequired |
function |
— | Called with the location result when geolocation determines consent isn’t needed. |
Cookie categories
Three categories are built in:
| Id | Name | Required |
|---|---|---|
necessary |
Necessary | yes — shown as “Always active” |
analytics |
Analytics | no |
marketing |
Marketing | no |
Replace them with your own by passing categories. Every field is required — including description:
CookieDialog.init({
categories: [
{
id: 'necessary',
name: 'Essential Cookies',
description: 'Required for the website to function',
required: true
},
{
id: 'analytics',
name: 'Analytics',
description: 'Help us understand how you use our site',
required: false
},
{
id: 'ads',
name: 'Advertising',
description: 'Used for targeted advertising',
required: false
}
]
});
Categories with required: true are always saved as true, even when the visitor clicks Reject all. Optional categories start switched off in the preferences panel.
Translations
Pass translations to localize or reword the dialog. Keys you leave out keep their English default, so you only need to supply what you want to change:
CookieDialog.init({
translations: {
title: 'We value your privacy',
description: 'We use cookies to enhance your browsing experience and analyze our traffic. You can accept all cookies, reject the optional ones, or choose which categories to allow.',
acceptButton: 'Accept all',
rejectButton: 'Reject all',
settingsButton: 'Manage preferences',
saveButton: 'Save preferences',
privacyLink: 'Privacy Policy',
cookiePolicyLink: 'Cookie Policy',
necessaryCategory: 'Necessary',
necessaryDescription: 'Essential cookies for the website to function properly',
alwaysActive: 'Always active',
settingsTitle: 'Cookie preferences'
}
});
The values above are the defaults. necessaryCategory and necessaryDescription rename the built-in required category; alwaysActive is the badge shown next to required categories; settingsTitle is the accessible name screen readers announce for the preferences panel. The older closeButton key still works as a fallback for saveButton. No locale bundles ship with the library — detect the language yourself (for example from navigator.language) and pass the matching strings.
Geolocation
With enableLocation: true, init() calls an IP geolocation API (by default ipapi.co) before showing anything:
- Visitor in the EU/EEA or UK — the dialog shows normally.
- Visitor elsewhere — all categories are auto-accepted with reason
location_not_required,onLocationNotRequiredthenonAcceptfire, and the dialog never renders. - Lookup fails — CookieDialog fails safe: it assumes consent is required and shows the dialog.
CookieDialog.init({
enableLocation: true,
onLocationNotRequired: (location) => {
console.log('Consent not required for', location.country);
},
onAccept: (consent) => {
if (consent.reason === 'location_not_required') {
// auto-accepted by location, consent.locationData has the details
}
}
});
Results are cached in memory for one hour. The region list covers the 27 EU member states plus the UK, Iceland, Liechtenstein, and Norway. Set debug: true to see the lookup result in the console.
Two things to keep in mind:
- The visitor’s IP address is sent to the geolocation service — mention that in your privacy policy, or leave
enableLocationoff (the default) so every visitor simply sees the dialog. - ipapi.co’s free tier is rate-limited; for real traffic use a paid plan or your own endpoint.
Custom endpoints
Point geolocationEndpoint at your own API to keep lookups first-party. Custom endpoints must return an inEU (or in_eu) boolean — a response with only a country code is treated as outside the EU:
{ "inEU": true, "country": "Germany", "region": "Bavaria" }
CookieDialog.init({
enableLocation: true,
geolocationEndpoint: 'https://your-api.example.com/geo'
});
API reference
CookieDialog.init(config) returns the dialog instance. All methods return plain values — there is no chaining.
| Method | Returns | Description |
|---|---|---|
init() |
Promise<void> |
Checks stored consent, runs geolocation if enabled, then shows the dialog (unless autoShow: false). |
show() |
void |
Shows the dialog, rendering it first if needed. Toggles reflect the currently stored consent. |
hide() |
void |
Hides the dialog without saving anything (the DOM stays in place). |
destroy() |
void |
Removes the dialog and overlay from the DOM. |
getConsent() |
object | null |
The stored consent state, or null if missing or expired. |
hasConsent() |
boolean |
Whether valid consent is stored. |
getCategoryConsent(id) |
boolean |
Whether a specific category was accepted. |
resetConsent() |
void |
Clears stored consent. Call show() afterwards to re-prompt. |
const dialog = CookieDialog.init({ autoShow: false });
if (!dialog.hasConsent()) {
dialog.show();
}
if (dialog.getCategoryConsent('analytics')) {
// load your analytics script
}
Consent state
getConsent(), onAccept, onReject, and onChange all deal in the same object:
{
"timestamp": 1640995200000,
"categories": { "necessary": true, "analytics": false, "marketing": true },
"version": "1.0.0",
"reason": "user_accept",
"locationData": {
"country": "US",
"region": "California",
"inEU": false,
"detectionMethod": "ip_geolocation"
}
}
reason is user_accept, user_reject, or location_not_required. Saving from the preferences panel records user_accept when at least one optional category is on and user_reject otherwise. locationData is present only when geolocation made the decision.
Storage
Consent lives in localStorage under the key cookiedialog_consent — the library itself sets no cookies. Consent expires expiryDays after it was saved, and a stored schema version guards against format changes. To reset during testing:
localStorage.removeItem('cookiedialog_consent');
Styling
The dialog is styled with CSS custom properties. Override them on .cookie-dialog (or :root) to match your brand without touching any selectors:
.cookie-dialog {
--cd-accent: #C2368F; /* buttons, toggles, links */
--cd-accent-hover: #8B2FA8;
--cd-accent-text: #ffffff; /* text on the accent colour */
--cd-bg: #ffffff;
--cd-text: #15171a;
--cd-text-muted: #394047;
--cd-border: #e5eaed;
--cd-radius: 12px; /* dialog corners */
--cd-button-radius: 8px;
--cd-font-family: inherit; /* use the page font */
--cd-max-width: 1100px; /* content width for the banner positions */
}
The full list:
| Variable | Controls |
|---|---|
--cd-accent, --cd-accent-hover, --cd-accent-text |
Primary button, outlined buttons, toggles, links |
--cd-accent-soft |
Hover background for outlined and text buttons |
--cd-bg, --cd-text, --cd-text-muted, --cd-border |
Surface, body text, secondary text, dividers |
--cd-toggle-off, --cd-badge-bg |
Toggle track when off, “Always active” badge |
--cd-radius, --cd-button-radius |
Corner radii |
--cd-shadow, --cd-overlay |
Dialog shadow, modal backdrop colour |
--cd-focus-ring |
Keyboard focus outline |
--cd-font-family, --cd-font-size |
Typography |
--cd-max-width |
Content width inside top/bottom banners |
--cd-duration |
Open/close animation length |
theme: 'dark' swaps in a dark palette for the same variables, and theme: 'auto' does so under prefers-color-scheme: dark. If your site has its own light/dark switch, keep theme: 'light' and set the variables from your own theme tokens — that is how this site does it, so the dialog follows the header toggle.
For deeper changes target the classes directly. The most useful hooks:
| Class | Element |
|---|---|
.cookie-dialog |
Root container (also carries position-*, theme-*, and settings-open classes) |
.cookie-dialog-title / .cookie-dialog-description |
Heading and body text |
.cookie-dialog-links |
Row of policy links |
.cookie-dialog-button |
All buttons |
.cookie-dialog-button-accept / -reject / -settings |
Individual buttons |
.cookie-dialog-settings |
The preferences panel |
.cookie-dialog-category |
One category row |
.cookie-dialog-badge |
The “Always active” badge |
.cookie-dialog-toggle-slider |
The toggle switch |
.cookie-dialog-overlay |
Backdrop for position: 'center' |
The dialog uses z-index: 999999, caps its height at 90% of the viewport and scrolls inside that, respects the iPhone home-bar safe area, and stacks buttons vertically on screens under 640px wide.
Loading analytics on consent
The main use case: keep tracking scripts unloaded until the visitor opts in. With Google Analytics 4 and Consent Mode v2, deny everything by default, then update from the dialog’s callbacks:
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500
});
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
function applyConsent(consent) {
gtag('consent', 'update', {
analytics_storage: consent.categories.analytics ? 'granted' : 'denied',
ad_storage: consent.categories.marketing ? 'granted' : 'denied',
ad_user_data: consent.categories.marketing ? 'granted' : 'denied',
ad_personalization: consent.categories.marketing ? 'granted' : 'denied'
});
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
}
CookieDialog.init({
onAccept: applyConsent,
onReject: applyConsent
});
</script>
Because onAccept and onReject both receive the consent state and cover every way the dialog can close with a decision, those two callbacks are enough. The same pattern works for Microsoft Clarity (clarity('consentv2', …)), Facebook Pixel, chat widgets, and anything else that should wait for a yes: put the loader inside a function and call it when the relevant category is true.
TypeScript
Type declarations ship with the package:
import CookieDialog from 'cookiedialog';
import type { CookieDialogConfig, ConsentState } from 'cookiedialog';
const config: CookieDialogConfig = {
theme: 'auto',
onAccept: (consent: ConsentState) => {
console.log(consent.categories);
}
};
CookieDialog.init(config);
CookieCategory, Translations, ConsentReason, and GeolocationResponse are exported from the package root as well.
Single-page apps and SSR
CookieDialog touches document and localStorage, so initialize it client-side only — in a useEffect (React), onMounted (Vue), or afterNextRender (Angular). Create it once at app startup rather than per component, and call destroy() on teardown:
useEffect(() => {
const dialog = CookieDialog.init({ autoShow: true });
return () => dialog.destroy();
}, []);
Notes and limits
- Config strings (titles, descriptions, category names, URLs) are HTML-escaped before rendering, so markup in them is shown literally rather than interpreted.
- Clicking the overlay or pressing Escape closes the dialog without recording a decision; it will show again on the next page load.
- Browser support: Chrome/Edge 88+, Firefox 78+, Safari 14+.
Changelog
1.1.0
- Redesigned preferences flow: Manage preferences replaces the action row with per-category toggles plus Save, Accept all, and Reject all.
- Required categories show an “Always active” badge; Reject all now has the same weight as Accept all.
- Re-opening the dialog shows the visitor’s stored choices.
- Accessibility: dialog roles and labels, focus management, Escape to close, focus trapping for the modal, visible focus rings, reduced-motion support.
- Theming via
--cd-*custom properties;theme: 'auto'now works. - New
debugoption; nothing is logged to the console by default. translationsmerges with the defaults instead of replacing them. New keys:saveButton,alwaysActive,settingsTitle.- Saving preferences now fires
onAcceptoronRejectas well asonChange.onRejectreceives the consent state. - Config strings are escaped; no global element ids, so multiple pages and instances don’t collide.
- Layout: max height with scrolling, safe-area padding, proper links row, exit animations.
Get help
Bugs and feature requests: GitHub issues. The repo includes a demo page (npm run demo) that exercises every position, theme, and callback — handy for trying settings before wiring them into your site.