@alexpricedev/billet-cookie-consent
v0.2.0
Published
Standalone, GDPR-friendly cookie consent banner. Zero dependencies, vanilla DOM, scoped CSS tokens. Designed for Billet but framework-agnostic.
Maintainers
Readme
@alexpricedev/billet-cookie-consent
GDPR-friendly cookie consent banner. Vanilla DOM, zero dependencies, scoped CSS tokens. Framework-agnostic — designed to drop into Billet but works anywhere.
Copy this to your coding agent
Paste this into your agent (Claude Code, Cursor, etc.):
Install and wire up @alexpricedev/billet-cookie-consent in this repo.
1. Read the package README first:
node_modules/@alexpricedev/billet-cookie-consent/README.md
(or https://github.com/alexpricedev/billet-cookie-consent#readme).
Follow its "Wire up in 3 steps" section. Adapt snippets to this
repo's controller / template signatures where they differ — don't
paste blindly.
2. If this repo has a CLAUDE.md or AGENTS.md, read it and respect its
conventions (filenames, lint rules, where tests go). Those override
anything generic in the package README.
3. Use three categories: necessary (required), analytics, marketing.
Set policyUrl to "/privacy" — and add a /privacy stub page if one
doesn't exist, with a co-located test.
4. Add a "Manage cookies" footer button as shown in the README's
step 2. GDPR requires users be able to withdraw consent.
5. Apply the README's Theming block: map this repo's existing
--color-* / --font-* (or equivalent) tokens to --cc-* on
[data-cc-root]. Otherwise the banner will ship in the package's
default look instead of matching the site.
6. Verify with the repo's check/test commands, plus the /browse skill
(or equivalent headless browser tool): banner renders → reject →
reload-no-banner → manage-cookies-reopens-modal → /privacy returns
200. Do not scaffold a new browser-test harness — use whatever's
already wired in.
7. Open a PR.
- Title: "Add GDPR cookie consent" — but check `git log` first
and adapt to the repo's commit convention if it uses one
(e.g. conventional commits → `feat(consent): add GDPR cookie
consent`). Commit conventions usually live in history, not in
CLAUDE.md.
- Body: short summary of what was wired + the verification
steps you performed.
- Screenshot: `gh pr create --body` can't embed local images.
Capture the banner with the browser tool and attach it as a
follow-up PR comment (`gh pr comment <n> --body-file …`) or
via the GitHub web UI.
Out of scope: real analytics scripts, real privacy copy, translations,
and server-side parseConsent wiring (defer until a real analytics
script is added).For AI agents
- Package:
@alexpricedev/billet-cookie-consent - Install:
bun add @alexpricedev/billet-cookie-consent - Peer deps: none
- Bundle side effects: appends one
<div data-cc-root>to<body>oninit(); writes one cookie (default namecc_consent) - Server runtime: any (
./serverexport is a pure function over a Cookie header string) - Wiring: 3 client steps + 1 optional server step — see Wire up in 3 steps
- Verify install:
bun testin the consumer should still pass;document.querySelector('[data-cc-root]')returns a node after init
Install
bun add @alexpricedev/billet-cookie-consent
# or: npm install / pnpm add / yarn addWire up in 3 steps
1. Import the CSS once
In your global stylesheet (e.g. src/client/style.css):
@import "@alexpricedev/billet-cookie-consent/styles.css";2. Initialize the banner on the client
Create a file that runs on every page (e.g. src/client/pages/consent.ts for Billet, or your existing client entry):
import { CookieConsent } from "@alexpricedev/billet-cookie-consent";
const consent = CookieConsent.init({
categories: [
{ id: "necessary", label: "Strictly necessary", required: true,
description: "Required for the site to function. Always on." },
{ id: "analytics", label: "Analytics",
description: "Helps us understand how the site is used." },
{ id: "marketing", label: "Marketing",
description: "Used to personalize ads and content." },
],
policyUrl: "/privacy",
});
if (consent.has("analytics")) loadAnalytics();
document.addEventListener("cc:consent-granted", (e) => {
if (e.detail.category === "analytics") loadAnalytics();
});
// "Manage cookies" footer trigger. Use a data attribute on the server-
// rendered button — never inline onclick — so this fits a server-JSX +
// islands pattern (e.g. Billet).
for (const el of document.querySelectorAll<HTMLElement>("[data-cc-open-prefs]")) {
el.addEventListener("click", () => CookieConsent.current()?.show());
}
function loadAnalytics(): void {
// Replace with your analytics loader. Idempotent: only run once.
}In your footer template, render the trigger button:
<button type="button" data-cc-open-prefs>Manage cookies</button>Wire the init file into your client entry. In Billet, register it in src/client/main.ts:
import "./pages/consent";3. (Optional) Server-side gating
To avoid sending analytics <script> tags to users who haven't opted in, read the cookie in your request handler and pass the consent state to your template.
import { parseConsent } from "@alexpricedev/billet-cookie-consent/server";
const consent = parseConsent(req.headers.get("cookie"));
// consent?.state.analytics === true | falseIn a Billet view controller, thread it into the template:
import { parseConsent } from "@alexpricedev/billet-cookie-consent/server";
export const home = {
index(req: Request): Response {
const consent = parseConsent(req.headers.get("cookie"));
return render(<Home analytics={consent?.state.analytics === true} />);
},
};Then in Layout (or the page template):
{props.analytics && <script src="https://plausible.io/js/script.js" defer />}Decision table
When wiring this up, you'll need to pick values. Sensible defaults are listed; change only when the listed condition applies.
| Option | Default | Change when |
|---|---|---|
| cookieName | cc_consent | Your app already uses this name for something else |
| cookieMaxAgeDays | 365 | Your jurisdiction requires a shorter re-prompt window |
| cookieSameSite | Lax | You serve this widget on a third-party origin (use None + secure: true) |
| cookieSecure | true on HTTPS, else false | You need to test on plain HTTP and want to force one or the other |
| position | bottom | Host UI has a fixed bottom bar — use top, bottom-left, or bottom-right |
| policyUrl | none | You have a privacy policy page (recommended for GDPR compliance) |
| categories | required, no default | Always supply. Minimum: [{ id: "necessary", label: "Necessary", required: true }] |
| texts | English defaults | You need a different language or wording |
| autoShow | true | You want to gate rendering on additional logic before showing the banner |
Category fields
Each entry in categories accepts:
| Field | Type | Notes |
|---|---|---|
| id | string (required) | Stable identifier used in the cookie and in has(id). Must be unique. |
| label | string (required) | Shown in the preferences modal. |
| description | string | Optional helper text under the label. |
| required | boolean | Forces the category on and disables its toggle. Use for strictly-necessary cookies. |
| default | boolean | Pre-ticks the category on first visit while leaving the toggle editable. Counts as granted if the user saves without touching it. |
default vs required. required is non-negotiable — always on, toggle disabled. default is a starting position the user can change — pre-ticked but editable, and "Reject all" turns it off. required wins: a category with both is always on. default only affects the first visit (and reset()); a returning user's saved choice is never overridden.
Compliance caveat. Under the GDPR/ePrivacy Directive, consent for non-essential cookies must be a deliberate opt-in. Pre-ticked boxes do not constitute valid consent (CJEU, Planet49, C-673/17). Only use
default: truewhere you have a lawful basis and have considered the regulatory risk; in most cases consumers should opt in deliberately.
Theming
All design tokens are CSS custom properties scoped to [data-cc-root]. To theme, override them on the same selector (or any ancestor):
[data-cc-root] {
--cc-bg: var(--color-surface);
--cc-fg: var(--color-text);
--cc-primary: var(--color-primary);
--cc-primary-fg:#ffffff;
--cc-font: var(--font-main);
--cc-radius: 8px;
}The package never reads from host token names (--color-* etc) — you opt in by mapping your tokens to ours. Nothing leaks out of [data-cc-root].
Available tokens: --cc-bg, --cc-fg, --cc-muted, --cc-primary, --cc-primary-fg, --cc-surface, --cc-border, --cc-radius, --cc-radius-sm, --cc-font, --cc-shadow, --cc-z, --cc-gap, --cc-pad, --cc-max-w.
API reference
CookieConsent.init(config): ConsentController
Renders the banner if no valid cookie is present, otherwise stays silent. Throws if called twice — call controller.destroy() first if you need to re-init.
Side effects: appends <div data-cc-root> to <body>; reads document.cookie.
CookieConsent.current(): ConsentController | null
Returns the active controller (or null if init hasn't run yet). Use this from code that doesn't have a direct reference to the controller — e.g. a global "Manage cookies" click handler.
controller.has(category): boolean
True iff the category is currently granted. Required categories are always true once a record exists.
controller.get(): ConsentMap
Returns a copy of the current consent map: { [categoryId]: boolean }.
controller.record(): ConsentRecord | null
Returns the full stored record ({ v, ts, cats }) or null if the user hasn't decided yet.
controller.acceptAll() / controller.rejectAll()
Persist all-on / all-off (required categories stay on). Dismisses banner and modal. Fires events.
controller.save(state: ConsentMap)
Persist a specific consent map. Required categories are forced on. Fires events.
controller.show()
Open the preferences modal. Use this for "Manage cookies" links in your footer (see step 2 above for the data-attribute pattern).
controller.reset()
Clear the cookie and re-show the banner. For testing or "withdraw consent" flows.
controller.destroy()
Remove DOM, allow init() to be called again. Useful for HMR.
parseConsent(cookieHeaderOrJar, opts?) (server)
import { parseConsent } from "@alexpricedev/billet-cookie-consent/server";
parseConsent(req.headers.get("cookie")); // → { state, record } | null
parseConsent({ cc_consent: "..." }); // accepts pre-parsed jar
parseConsent(header, { cookieName: "custom" }); // override cookie namePure function. Returns null on missing/malformed/wrong-version cookie.
hasConsent(cookieHeaderOrJar, category, opts?) (server)
Convenience boolean check: hasConsent(req.headers.get("cookie"), "analytics").
Events
Dispatched on document as CustomEvent:
| Event | Detail |
|---|---|
| cc:consent-changed | { state: ConsentMap, record: ConsentRecord } — fires on every save |
| cc:consent-granted | { category: string } — fires once per newly-granted category |
| cc:consent-revoked | { category: string } — fires once per newly-revoked category |
document.addEventListener("cc:consent-granted", (e) => {
if (e.detail.category === "analytics") loadAnalytics();
});Cookie format
The cookie value is URL-encoded JSON:
{ "v": 1, "ts": 1736899200000, "cats": { "necessary": true, "analytics": false } }vis the schema version. Future versions invalidate older cookies (user is re-prompted).tsis the decision timestamp in unix ms.catsmaps every configured category id to a boolean.
If the configured category set ever drifts from the stored set (you add a new category), the cookie is treated as stale and the banner re-prompts.
Common pitfalls
- Do not set
HttpOnlyon the consent cookie. The client must read it to know whether to load gated scripts. - Do not call
CookieConsent.init()more than once per page — it throws. Usecontroller.destroy()first if you must. - Do not use inline
onclick=for the "Manage cookies" button in a server-JSX codebase. Use the data-attribute + client-side listener pattern shown in step 2. - Do not import
parseConsent"for discoverability" without using it — strict TypeScript / lint setups (noUnusedLocals) will reject it. Add a code comment instead, and import only when you have a real script to gate. - Do not assume
parseConsentreturningnullmeans "no consent". It means "no decision yet" — treat it the same as all-off but consider whether to re-prompt. - The package never sets analytics cookies for you. It only stores the user's choice. You are responsible for conditionally loading your own analytics/marketing scripts.
- Required categories are forced on at write time. If a user toggles
necessaryoff in dev tools, the next save re-enables it.
Verifying the install
After wiring up, run in your host project:
bun test # your existing tests still pass
bun run check # your existing lint/typecheck still passesThen in the browser (or via a headless browser tool):
- Hard-reload — banner appears.
- Click "Reject all" — banner disappears;
document.cookiecontainscc_consent=…. - Reload — no banner.
- Run
document.querySelector('[data-cc-root]')— returns a node. - Open preferences via your footer link — modal appears, ESC closes it.
Development
bun install
bun test # 60+ tests, ~99% line coverage
bun run typecheck # tsc --noEmit, strict modeLicense
MIT © Alex Price
