npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mihilista/cookie-consent

v4.0.0

Published

GDPR-compliant cookie consent manager for Next.js with Google Consent Mode v2 and Meta Pixel integration. Ships English and Czech copy.

Readme

Next.js Cookie Consent Manager 🍪

A GDPR-compliant cookie consent manager for Next.js, with Google Consent Mode v2 and Meta Pixel integration built in. Ships React components, a context provider, and a consent-initialization script that wires correctly with Google Tag Manager.


✨ Features

  • English and Czech built in: locale="en" (default) or locale="cs", with every string overridable on top
  • Google Consent Mode v2: analytics_storage, ad_storage, ad_user_data, ad_personalization
  • Meta Pixel consent handling: automatic fbq('consent', 'grant'|'revoke') with retry until pixel loads
  • Pre-GTM consent defaults: <ConsentInitScript /> sets denied before any tracking loads (GDPR-safe by default)
  • Region-aware variant: <ConsentInitScriptWithRegions /> for EEA/US/California differentiation
  • Cookie persistence: 365 days when accepted, 1 day when rejected
  • Cross-subdomain consent: one decision shared between example.com and app.example.com via cookieDomain
  • Granular categories: Required (always on), Analytics, Marketing
  • Accessible preferences modal: focus trap, focus restore, scroll lock, visible focus rings, and keyboard-reachable toggles. Closes via the X button, overlay click, or Esc
  • Themeable styling: default CSS is a separate import; brand it via --cc-* CSS variables or replace it entirely
  • Tested: 39 jsdom checks over consent storage, dataLayer events, cross-subdomain behaviour and locale resolution

📦 Installation

npm install @mihilista/cookie-consent
# or
pnpm add @mihilista/cookie-consent
# or
yarn add @mihilista/cookie-consent

Peer deps: React >= 18.2, React DOM >= 18.2.

Upgrading from 3.x

The default copy is now English. If your site is Czech and you relied on the built-in strings, add one prop:

<CookieConsentProvider locale="cs">

If you already pass your own locale object, nothing changes. Two smaller breaks are worth a look before you upgrade: the banner's body no longer suppresses the privacy-policy link (pass link: null for that), and the checkbox markup changed, which matters only if you wrote custom CSS against it. Full list in CHANGELOG.md.


🚀 Quick Start (Next.js App Router)

The implementation order is critical for GDPR compliance. ConsentInitScript must run before GTM so default consent is denied before any tag loads.

// app/layout.tsx
import '@mihilista/cookie-consent/styles'; // optional, see Styling below
import {
  ConsentInitScript,
  CookieConsentProvider,
  CookieBanner,
  CookiePreferencesModal,
} from '@mihilista/cookie-consent';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {/* 1. CRITICAL: set default consent to denied BEFORE GTM */}
        <ConsentInitScript />

        {/* 2. Load Google Tag Manager AFTER ConsentInitScript */}
        <script
          dangerouslySetInnerHTML={{
            __html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
              new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
              j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
              'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
              })(window,document,'script','dataLayer','GTM-XXXXXXX');`,
          }}
        />
      </head>
      <body>
        {/* 3. Wrap the app and mount the banner + modal */}
        {/* locale defaults to "en"; pass locale="cs" for the built-in Czech copy */}
        <CookieConsentProvider>
          <CookieBanner />
          <CookiePreferencesModal />
          {children}
        </CookieConsentProvider>
      </body>
    </html>
  );
}

For region-specific defaults (EEA denied, US granted, California denied), swap <ConsentInitScript /> for <ConsentInitScriptWithRegions />. See IMPLEMENTATION_GUIDE.md.


🧩 API

Components

| Component | Purpose | |---|---| | ConsentInitScript | Inline script for <head>. Sets default Google Consent to denied, re-applies any saved consent. Must precede GTM. | | ConsentInitScriptWithRegions | Region-aware variant. Accepts regions={{ eea, us, california }}. | | CookieConsentProvider | Context provider. Wraps the app, manages state, handles GTM/Meta Pixel consent updates. Accepts locale and cookieDomain. | | CookieBanner | Bottom banner shown when no consent cookie exists. Accepts locale. | | CookiePreferencesModal | Granular preferences modal. Accepts locale. Traps focus while open and closes on X button, overlay click, or Esc. | | CookieConsent | Convenience wrapper that mounts Provider + Banner + Modal (use when you don't need custom layout). Accepts locale and cookieDomain. | | CookiePreferencesButton | Pre-styled button that reopens the preferences modal. Label follows the active locale. |

Hook: useCookieConsent()

Returns:

  • preferences: { analytics: boolean; marketing: boolean }
  • showBanner, setShowBanner
  • showPreferencesModal, setShowPreferencesModal
  • handleAccept(): accept all
  • handleReject(): reject all
  • savePreferences(analytics?, marketing?): save current or explicit choices
  • togglePreference('analytics' | 'marketing')
  • hasConsentCookie(): boolean
  • getConsentCookie(): ConsentCookie | null, the stored decision in Consent Mode v2 shape
  • locale: ConsentLocale, the resolved dictionary in use
  • CookiePreferencesButton: pre-styled button to reopen the modal (also a top-level export)
'use client';
import { useCookieConsent } from '@mihilista/cookie-consent';

export function CookieSettingsLink() {
  const { CookiePreferencesButton } = useCookieConsent();
  return <CookiePreferencesButton>Cookie settings</CookiePreferencesButton>;
}

Cookie Storage

A single userConsent cookie is written in Google Consent Mode v2 format:

{
  "analytics_storage": "granted" | "denied",
  "ad_storage": "granted" | "denied",
  "ad_user_data": "granted" | "denied",
  "ad_personalization": "granted" | "denied"
}

Lifetime: 365 days if any category is granted, 1 day otherwise (so rejecters get re-prompted reasonably soon).

Cross-Subdomain Consent (cookieDomain)

By default the userConsent cookie is host-only: written on example.com, it is never sent to app.example.com. A visitor who accepts on the marketing site is asked again in the app, and the app's Meta Pixel ignores the decision they already made.

Pass cookieDomain on both properties to share one decision:

<CookieConsentProvider cookieDomain=".example.com">
  <CookieBanner />
  <CookiePreferencesModal />
  {children}
</CookieConsentProvider>

Reading was never the problem (the browser hands a parent-domain cookie to every subdomain, so ConsentInitScript and useCookieConsent already saw it). Only the write needed the Domain attribute.

Rules:

  • Use the leading dot form, .example.com. Setting a Domain at all is what widens a cookie from host-only to the domain plus every subdomain; browsers strip the leading dot per RFC 6265 §5.2.3, so example.com behaves identically, but the dotted spelling makes the cross-subdomain intent unmistakable.
  • It must cover the host being served, and must not be a public suffix like .com or .co.uk. When the browser cannot accept the value, the package stores a host-only cookie instead (v3.4.0+), so the decision always persists, just unshared. Development builds console.warn with the reason; production builds stay silent.
  • It is safe to leave set in local development and on preview URLs. .example.com covers neither localhost nor *.vercel.app, so consent there is simply stored host-only and the banner does not come back on every reload. No env var plumbing needed.
  • Omitting the prop changes nothing. No Domain attribute is written and the cookie is byte for byte what earlier versions produced.
  • Enabling it on a live site leaves returning visitors holding both their old host-only cookie and the new shared one for a while. Both readers take the last match, which is the shared copy, and the leftover expires on its own schedule.

dataLayer Events

| Event | Pushed when | Payload | |---|---|---| | consent_updated | The visitor makes or changes a decision in this page view (banner or preferences modal) | consent_analytics: boolean, consent_marketing: boolean | | consent_restored | A stored decision is re-applied on mount, i.e. a returning visitor | Same field names and types |

The payloads are identical by design, so a single GTM Custom Event trigger with Use regex matching and consent_updated|consent_restored covers both. consent_restored fires at most once per page load and never accompanies the consent_updated of the same decision.

Most tags do not need this trigger: GTM releases blocked tags by itself once gtag('consent', 'update', ...) grants what they require, and the provider calls that in both cases. Use the events for anything that must fire explicitly on a consent change.


🌐 Localization

The package ships two complete dictionaries, English (default) and Czech. Pick one with locale, override individual strings with an object, or supply your own translations entirely.

Pick a built-in language

<CookieConsentProvider locale="cs">
  <CookieBanner />
  <CookiePreferencesModal />
  {children}
</CookieConsentProvider>

One prop covers every visible string: the banner, the modal, the preferences button label, the modal's close-button label and the banner's landmark name. Omit it and you get English.

Override individual strings

Pass an object instead of a language. Overrides deep-merge onto the active dictionary, so you write only what differs and the rest stays translated:

<CookieConsentProvider
  locale={{
    language: 'cs',
    banner: { title: 'Vlastní titulek' },
    modal: { buttons: { save: 'Uložit' } },
  }}
>

language names the dictionary the overrides build on and defaults to en.

Per-component overrides

CookieBanner and CookiePreferencesModal take the same prop and win over the provider for themselves:

<CookieBanner locale={{ linkHref: '/legal/privacy' }} />
<CookiePreferencesModal locale="cs" />

The privacy-policy link

The banner appends a link after its body text. Three ways to steer it:

| Goal | What to pass | |---|---| | Change where it points or what it says | linkHref and linkText | | Use a framework link | link: <Link href="/legal/privacy">Privacy Policy</Link> | | No generated link, because body already contains one | link: null |

A passed link element keeps its own className and gains cc--banner--typo--bodyLink.

Changed in 4.0. Setting body used to remove the link silently. The two are independent now, so a body with its own link must also pass link: null.

Driving the language from your router

A router locale is a string, and the locale prop wants 'en' | 'cs'. isConsentLanguage narrows it:

import { CookieConsentProvider, isConsentLanguage } from '@mihilista/cookie-consent';

const { locale } = await params;

<CookieConsentProvider locale={isConsentLanguage(locale) ? locale : 'en'}>

With next-intl

// components/cookie-banner-and-modal.tsx
'use client';

import { CookieBanner, CookiePreferencesModal } from '@mihilista/cookie-consent';
import { useTranslations } from 'next-intl';
import Link from 'next/link';

export default function CookieBannerAndModal() {
  const t = useTranslations('cookies');

  const bannerLocale = {
    title: t('banner.title'),
    body: t.rich('banner.body', {
      policy: (chunks) => (
        <Link href="/legal/privacy-policy" className="underline">
          {chunks}
        </Link>
      ),
    }),
    // the rich body already carries the link
    link: null,
    buttons: {
      manage: t('banner.buttons.manage'),
      accept: t('banner.buttons.accept'),
      reject: t('banner.buttons.reject'),
    },
  };

  return (
    <>
      <CookieBanner locale={bannerLocale} />
      <CookiePreferencesModal locale={t.raw('modal')} />
    </>
  );
}

Building a third language

Export the built-in dictionaries, copy the shape, and pass the result as the locale object:

import { en, type ConsentLocale } from '@mihilista/cookie-consent';

const de: ConsentLocale = {
  ...en,
  banner: { ...en.banner, title: 'Cookies verbessern Ihr Erlebnis' },
};

<CookieConsentProvider locale={de}>

LOCALES ({ en, cs }) and DEFAULT_LANGUAGE are exported too, along with the ConsentLocale, BannerLocale and ModalLocale types, so a missing key is a compile error rather than a blank button.


🎨 Styling

Option 1: Use default styles

import '@mihilista/cookie-consent/styles';

The same stylesheet is also published as a plain CSS file, for setups where a JS module that imports CSS is awkward:

@import '@mihilista/cookie-consent/styles.css';

Option 1b: Theme via CSS variables (recommended)

Import the default styles, then override the --cc-* custom properties on :root to match your brand. Every color, radius and shadow is a variable; the defaults reproduce the original dark look, so you only set what you want to change.

:root {
  --cc-bg: #fffdf8;            /* container background */
  --cc-fg: #1a1a1a;            /* text */
  --cc-radius: 0.75rem;        /* container corners */
  --cc-button-radius: 0.375rem;/* button + checkbox corners */
  --cc-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.25);
  --cc-shadow-modal: 0 20px 60px -15px rgba(0, 0, 0, 0.35);
  --cc-border: rgba(0, 0, 0, 0.12);

  --cc-action-bg: #1a1a1a;     /* primary button (accept / save) */
  --cc-action-fg: #fffdf8;
  --cc-action-border: #1a1a1a;
  --cc-action-bg-hover: #333;
  --cc-action-border-hover: #333;

  --cc-overlay: rgba(0, 0, 0, 0.5);
  --cc-close: rgba(0, 0, 0, 0.6);
  --cc-close-hover: #1a1a1a;

  --cc-checkbox-border: rgba(0, 0, 0, 0.12);
  --cc-checkbox-checked-bg: #1a1a1a;
  --cc-checkbox-checked-fg: #fffdf8;

  --cc-focus: #1a1a1a;         /* keyboard focus ring */
  --cc-focus-width: 2px;
  --cc-focus-offset: 2px;
}

Because these are plain CSS variables, you can point them at your own design tokens (e.g. --cc-bg: var(--color-card)) so the banner tracks your palette automatically. The full list lives in dist/cookie-consent.css.

--cc-focus defaults to currentColor, which is legible on the default dark surface. Set it explicitly if you re-theme the background, and keep the contrast: the focus ring is the only thing telling a keyboard user where they are.

Option 2: Bring your own CSS

Skip the styles import. All components carry classes with the cc-- prefix (e.g. cc--banner--container, cc--modal--overlay, cc--buttonBase). dist/cookie-consent.css in the installed package is the authoritative list.

Two rules a replacement stylesheet has to honour, because accessibility depends on them:

  • Keep .cc--modal--checkboxInput visually hidden but focusable. Hiding it with display: none or visibility: hidden removes it from the tab order, and the consent categories become impossible to change by keyboard. Use the clip-rect pattern the default stylesheet uses.
  • Give every control a visible :focus-visible style. The defaults set outline: none on buttons and put the ring back on :focus-visible; if you drop the second half, keyboard users get no indicator at all.

Option 3: Hybrid

import '@mihilista/cookie-consent/styles';
import './cookie-consent-overrides.css';

🛡️ GDPR Compliance

This package handles the consent state correctly, but GTM tag configuration is your responsibility. Every analytics/marketing tag in your GTM container must be configured with consent requirements, otherwise tags fire regardless of user choice.

Minimum tag setup:

  • GA4: require analytics_storage
  • Meta Pixel: require ad_storage, ad_user_data, ad_personalization. Pixel code must start with fbq('consent', 'revoke').
  • Google Ads: require ad_storage, ad_user_data, ad_personalization

Full walkthrough in IMPLEMENTATION_GUIDE.md.


📖 Further Reading


🔗 Links