RoboDodd
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.

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, onLocationNotRequired then onAccept fire, 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 enableLocation off (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
}

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 debug option; nothing is logged to the console by default.
  • translations merges with the defaults instead of replacing them. New keys: saveButton, alwaysActive, settingsTitle.
  • Saving preferences now fires onAccept or onReject as well as onChange. onReject receives 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.