consent — consent that isn't a dark pattern
Stage: Phase 7 — Backend/compliance (+ Phase 11 gate) - Reads: design/BRIEF.md (which third parties?), design/SYSTEM.md (tokens), design/DIRECTION.md (shape/motion language) - Writes: components/consent/*, lib/consent.ts, third-party wiring behind the gate, footer reopen link
When NOT to show a banner — decide this first
The best banner is no banner. Audit what actually touches the visitor's device before you build anything, because most of the audit ends in deletion:
- Zero non-essential storage → no banner. §25(2) TTDSG exempts what is strictly necessary to deliver the service the user asked for: the session, cart, CSRF token, locale, and the consent record itself. A site with only these needs no consent and no banner. A banner over an essential-only site is theater that trains users to dismiss dialogs.
- Cookieless analytics → usually no banner. Plausible / Umami / Fathom read and write nothing on the device; under §25(2) they need no opt-in. Prefer them — swapping GA4 for Plausible can delete the banner outright. Verify the tool truly sets no device storage before you claim the exemption; "anonymized IP" GA is not exempt.
- Self-hosted fonts → no font-CDN consent. The stack self-hosts via
next/font. If fonts.googleapis.com or fonts.gstatic.com appears in source you have both a slop hit and a legal one (LG München I, 3 O 17493/20 — €100 damages for leaking an IP to Google) — fix the source, do not add a banner for it.
- "Might add analytics later" → build nothing now. The context and gate below retrofit in under an hour. Speculative consent infrastructure taxes every page today.
Only if a genuine non-essential third party survives the audit do you build the banner below — and you scope it to exactly the categories that survived.
Standard
Every rule here is simultaneously a taste rule and a legal one; that alignment is the whole point. A banner that nudges is a dark pattern whether or not a regulator ever sees it.
- Deny by default (opt-in, not opt-out). Nothing non-essential loads before an explicit click — no script, no pixel, no third-party iframe. §25 is prior consent; pre-loading "until they refuse" is the violation (Planet49, CJEU C-673/17).
- Accept and Reject at equal weight, on the first layer. Same size, same contrast, same shape and motion; reject reachable in the same number of clicks as accept (GDPR Art. 7(3); EDPB Taskforce + German DSK take the stricter CNIL line — a first-layer reject is the safe floor, and "Accept / Settings only" fails). Granular "Einstellungen" may be quieter — it is not the reject path, so demoting it is fine. Demoting reject is the dark pattern.
- No pre-ticked non-essential categories. Toggles default off; consent is an affirmative act, never a default state.
- Built in the site's own language. The banner uses the project's tokens — its neutral ramp, its
--radius-*, its shadow scale, its easing — never a generic gray OneTrust/Cookiebot/Usercentrics drop-in that reads as pasted from another site (and often ships a further third-party dependency and data transfer of its own).
- Consent is state the app reads. A first-party cookie plus a client context; script injection is downstream of that state, never hardcoded into the layout.
- Withdrawable as easily as granted. A persistent footer "Cookie-Einstellungen" link reopens the banner anytime (Art. 7(3)); withdrawing must cost no more than one extra click than granting did.
Process
- Inventory every third party from BRIEF.md and the built pages — analytics, Maps/YouTube/Vimeo embeds, chat widgets, font CDNs, ad/retargeting pixels, embedded Stripe/PayPal iframes on non-checkout pages. Kill what "When NOT" lets you kill.
- Categorize the survivors into the fewest named buckets — typically
analytics and embeds. Essential is implicit and never offered as a choice.
- Build the consent context (below) and mount
<ConsentProvider> in the root layout wrapping {children} as a server slot, so the provider is the only client boundary (ultraweb:app-structure).
- Gate every survivor. A third-party
<Script> renders only when useConsent() reports its category granted. An embed uses the two-click / Zwei-Klick pattern: a branded placeholder in the site's tokens, the real <iframe> mounts only after the visitor loads it — YouTube's -nocookie domain still writes on play, so gate the frame, not just the domain.
- If Google tags are unavoidable, wire Consent Mode v2: set
ad_storage, analytics_storage, ad_user_data, ad_personalization to denied by default and gtag('consent','update',…) only inside save(). The gate still applies — Consent Mode is a supplement, not a substitute for not loading the tag.
- Add the footer reopen link via
ultraweb:footer, calling reopen().
- Verify empirically: load the site fresh with the network tab open — nothing third-party may fire before a click. Then accept, reject, reload, and confirm the cookie persists the decision and the reject genuinely blocks.
ultraweb:ship re-checks this at launch.
Banner forms
Pick one that fits the direction; all three obey the equal-weight rule — the form is where the site's personality shows, never where the fairness bends.
- Bottom bar — a full-width strip pinned to the bottom, one line of copy + the two equal buttons + a quiet "Einstellungen". The default: least intrusive, never covers content, no scrim. Best for content and commerce sites.
- Corner card — a small card in one bottom corner, in the site's radius and shadow. For minimal/editorial directions where a full bar would feel heavy. Must not obscure primary CTAs at 375px.
- Centered modal + scrim — a focus-trapped dialog over a dim scrim. Use only when the brief legitimately needs a decision before interaction (rare — a strong nudge toward "just accept", so justify it). Never trap without a real reject on the first view; a modal with no equal reject is the worst-case dark pattern.
The consent context
// components/consent/consent-provider.tsx
"use client";
import { createContext, useContext, useEffect, useState } from "react";
export type Category = "analytics" | "embeds"; // essential is implicit, always on
export type Consent = Record<Category, boolean>;
const DENY: Consent = { analytics: false, embeds: false }; // §25 is opt-in — deny until granted
type Ctx = { consent: Consent; decided: boolean; save: (c: Consent) => void; reopen: () => void };
const ConsentCtx = createContext<Ctx | null>(null);
export const useConsent = () => {
const ctx = useContext(ConsentCtx);
if (!ctx) throw new Error("useConsent must be used within ConsentProvider");
return ctx;
};
export function ConsentProvider({ children }: { children: React.ReactNode }) {
const [consent, setConsent] = useState<Consent>(DENY);
const [decided, setDecided] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => { // rehydrate the prior decision
const raw = document.cookie.match(/(?:^|; )consent=([^;]+)/)?.[1];
if (raw) { setConsent(JSON.parse(decodeURIComponent(raw))); setDecided(true); }
}, []);
const save = (c: Consent) => {
document.cookie = `consent=${encodeURIComponent(JSON.stringify(c))}; Max-Age=15552000; Path=/; SameSite=Lax`;
setConsent(c); setDecided(true); setOpen(false); // 6-month record; re-ask, don't assume forever
};
return (
<ConsentCtx.Provider value={{ consent, decided, save, reopen: () => setOpen(true) }}>
{children}
{(!decided || open) && <ConsentBanner />} {/* Accept + Reject share ONE button variant */}
</ConsentCtx.Provider>
);
}
// components/consent/consent-embed.tsx — the two-click gate for Maps/YouTube
"use client";
import { useConsent } from "./consent-provider";
export function ConsentEmbed({ label, children }: { label: string; children: React.ReactNode }) {
const { consent, save } = useConsent();
if (consent.embeds) return <>{children}</>; // the iframe mounts ONLY after opt-in
return (
<div className="grid place-items-center gap-3 rounded-[var(--radius-lg)] border bg-card p-8 text-center">
<p className="text-sm text-muted-foreground">{label} lädt externe Inhalte von Dritten.</p>
<button className="rounded-[var(--radius-md)] bg-primary px-4 py-2 text-primary-foreground"
=> save({ ...consent, embeds: true })}>Inhalt laden</button>
</div>
);
}
The <ConsentBanner> renders two buttons from the same ultraweb:buttons variant — not primary vs ghost. A gated tag is a leaf: function Analytics(){ const {consent}=useConsent(); return consent.analytics ? <Script … /> : null; }.
Embeds & third-party governance
Generalize the Munich Google-Fonts ruling past fonts: the exposure is the request, not the cookie. Any origin your HTML reaches for — map tiles, a video frame, an avatar CDN, a chat widget — receives the visitor's IP and User-Agent before a single byte of storage is written, so the question is never "does it set a cookie" but "is the site allowed to contact this host at all". The cheapest answer is almost always deleting the origin rather than gating it.
- Maps render first-party by default. MapLibre GL against an OSM/MapTiler source you control, or — for one café pin — a static map image plus a plain "In Google Maps öffnen" link. Neither contacts Google from the page, so neither needs a category, a placeholder, or a banner.
- Google Maps only behind the click-to-load shim. If the brief insists on the interactive embed, it renders through
ConsentEmbed — branded placeholder in the site's tokens, real <iframe> mounted only after the visitor loads it. A "privacy-enhanced" host does not change this; gate the frame.
- Video embeds are the same problem. Self-host short clips via
ultraweb:media-optimization; YouTube/Vimeo go through the identical shim, never an eager iframe with a play button drawn over it.
- Analytics should stay out of this lane entirely —
ultraweb:analytics defaults to cookieless, which means no category, no shim, no banner. Only a cookie-based tool falls back into the gate above, and that is a reason to reconsider the tool.
Anti-patterns
- Unequal buttons — filled/colored Accept beside a gray text-link or ghost Reject. Grep the banner:
rg -n 'variant="(ghost|link|outline|secondary)"' components/consent — if the reject control's variant differs from accept's, it's a nudge.
- Reject buried behind "Einstellungen"/"Mehr Optionen" — a first layer with only Accept + Settings fails German enforcement; reject belongs on the first screen.
- Pre-ticked categories —
defaultChecked, checked={true}, or a non-false default in DENY. Consent is affirmative.
- Ungated third parties —
rg -n '<iframe[^>]*src="https://(www\.youtube|www\.youtube-nocookie|player\.vimeo|www\.google\.com/maps)' -g "*.tsx" outside a ConsentEmbed, or a third-party <Script src="https://…"> with no useConsent() guard.
- Google Fonts leak —
rg -n "fonts\.(googleapis|gstatic)\.com" (LG München); self-host via next/font, don't consent it away.
- A drop-in CMP as "the design" —
rg -ni "cookiebot|onetrust|usercentrics|cookieyes|iubenda"; an un-restyleable gray overlay violates the "built in the site's own language" rule and adds its own data transfer.
- No footer reopen link — consent that can't be withdrawn as easily as given breaks Art. 7(3).
- Loading on scroll/"implied consent" — firing tags because the user kept browsing; §25 needs an affirmative act, not the absence of one.
Worked example — Kaffeewerk Ost, Berlin roastery shop + /abo (German-first, TTDSG applies directly)
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: consent3description: GDPR/TTDSG §25 cookie and tracking consent as a design problem, not a bolted-on overlay — equal visual weight for Accept and Reject (never Accept-as-primary with Reject a buried gray link), a banner built from the site's own tokens, a consent-state React context that gates third-party script injection (analytics, Maps/YouTube embeds, chat, font CDNs) until granted, and a persistent footer "Cookie-Einstellungen" resurface link. Invoke in Phase 7 whenever a build loads any analytics, embed, chat widget, or third-party script, or when the user mentions cookies, consent, cookie banner, tracking, DSGVO/GDPR, TTDSG/TDDDG, opt-in, or "add a cookie notice".4---56# consent — consent that isn't a dark pattern78**Stage:** Phase 7 — Backend/compliance (+ Phase 11 gate) - **Reads:** design/BRIEF.md (which third parties?), design/SYSTEM.md (tokens), design/DIRECTION.md (shape/motion language) - **Writes:** components/consent/*, lib/consent.ts, third-party wiring behind the gate, footer reopen link910## When NOT to show a banner — decide this first1112The best banner is no banner. Audit what actually touches the visitor's device before you build anything, because most of the audit ends in deletion:1314- **Zero non-essential storage → no banner.** §25(2) TTDSG exempts what is *strictly necessary* to deliver the service the user asked for: the session, cart, CSRF token, locale, and the consent record itself. A site with only these needs no consent and no banner. A banner over an essential-only site is theater that trains users to dismiss dialogs.15- **Cookieless analytics → usually no banner.** Plausible / Umami / Fathom read and write nothing on the device; under §25(2) they need no opt-in. Prefer them — swapping GA4 for Plausible can delete the banner outright. Verify the tool truly sets no device storage before you claim the exemption; "anonymized IP" GA is *not* exempt.16- **Self-hosted fonts → no font-CDN consent.** The stack self-hosts via `next/font`. If `fonts.googleapis.com` or `fonts.gstatic.com` appears in source you have *both* a slop hit and a legal one (LG München I, 3 O 17493/20 — €100 damages for leaking an IP to Google) — fix the source, do not add a banner for it.17- **"Might add analytics later" → build nothing now.** The context and gate below retrofit in under an hour. Speculative consent infrastructure taxes every page today.1819Only if a genuine non-essential third party survives the audit do you build the banner below — and you scope it to exactly the categories that survived.2021## Standard2223Every rule here is simultaneously a taste rule and a legal one; that alignment is the whole point. A banner that nudges is a dark pattern whether or not a regulator ever sees it.2425- **Deny by default (opt-in, not opt-out).** Nothing non-essential loads before an explicit click — no script, no pixel, no third-party iframe. §25 is prior consent; pre-loading "until they refuse" is the violation (Planet49, CJEU C-673/17).26- **Accept and Reject at equal weight, on the first layer.** Same size, same contrast, same shape and motion; reject reachable in the *same number of clicks* as accept (GDPR Art. 7(3); EDPB Taskforce + German DSK take the stricter CNIL line — a first-layer reject is the safe floor, and "Accept / Settings only" fails). Granular "Einstellungen" may be quieter — it is not the reject path, so demoting it is fine. Demoting *reject* is the dark pattern.27- **No pre-ticked non-essential categories.** Toggles default off; consent is an affirmative act, never a default state.28- **Built in the site's own language.** The banner uses the project's tokens — its neutral ramp, its `--radius-*`, its shadow scale, its easing — never a generic gray OneTrust/Cookiebot/Usercentrics drop-in that reads as pasted from another site (and often ships a *further* third-party dependency and data transfer of its own).29- **Consent is state the app reads.** A first-party cookie plus a client context; script injection is downstream of that state, never hardcoded into the layout.30- **Withdrawable as easily as granted.** A persistent footer "Cookie-Einstellungen" link reopens the banner anytime (Art. 7(3)); withdrawing must cost no more than one extra click than granting did.3132## Process33341. **Inventory** every third party from BRIEF.md and the built pages — analytics, Maps/YouTube/Vimeo embeds, chat widgets, font CDNs, ad/retargeting pixels, embedded Stripe/PayPal iframes on non-checkout pages. Kill what "When NOT" lets you kill.352. **Categorize** the survivors into the *fewest* named buckets — typically `analytics` and `embeds`. Essential is implicit and never offered as a choice.363. **Build the consent context** (below) and mount `<ConsentProvider>` in the root layout wrapping `{children}` as a server slot, so the provider is the only client boundary (`ultraweb:app-structure`).374. **Gate every survivor.** A third-party `<Script>` renders only when `useConsent()` reports its category granted. An embed uses the two-click / *Zwei-Klick* pattern: a branded placeholder in the site's tokens, the real `<iframe>` mounts only after the visitor loads it — YouTube's `-nocookie` domain still writes on play, so gate the frame, not just the domain.385. **If Google tags are unavoidable,** wire Consent Mode v2: set `ad_storage`, `analytics_storage`, `ad_user_data`, `ad_personalization` to `denied` by default and `gtag('consent','update',…)` only inside `save()`. The gate still applies — Consent Mode is a supplement, not a substitute for not loading the tag.396. **Add the footer reopen link** via `ultraweb:footer`, calling `reopen()`.407. **Verify empirically:** load the site fresh with the network tab open — nothing third-party may fire before a click. Then accept, reject, reload, and confirm the cookie persists the decision and the reject genuinely blocks. `ultraweb:ship` re-checks this at launch.4142## Banner forms4344Pick one that fits the direction; all three obey the equal-weight rule — the form is where the site's personality shows, never where the fairness bends.45461. **Bottom bar** — a full-width strip pinned to the bottom, one line of copy + the two equal buttons + a quiet "Einstellungen". The default: least intrusive, never covers content, no scrim. Best for content and commerce sites.472. **Corner card** — a small card in one bottom corner, in the site's radius and shadow. For minimal/editorial directions where a full bar would feel heavy. Must not obscure primary CTAs at 375px.483. **Centered modal + scrim** — a focus-trapped dialog over a dim scrim. Use *only* when the brief legitimately needs a decision before interaction (rare — a strong nudge toward "just accept", so justify it). Never trap without a real reject on the first view; a modal with no equal reject is the worst-case dark pattern.4950## The consent context5152```tsx53// components/consent/consent-provider.tsx54"use client";55import { createContext, useContext, useEffect, useState } from "react";5657export type Category = "analytics" | "embeds"; // essential is implicit, always on58export type Consent = Record<Category, boolean>;59const DENY: Consent = { analytics: false, embeds: false }; // §25 is opt-in — deny until granted6061type Ctx = { consent: Consent; decided: boolean; save: (c: Consent) => void; reopen: () => void };62const ConsentCtx = createContext<Ctx | null>(null);63export const useConsent = () => {64 const ctx = useContext(ConsentCtx);65 if (!ctx) throw new Error("useConsent must be used within ConsentProvider");66 return ctx;67};6869export function ConsentProvider({ children }: { children: React.ReactNode }) {70 const [consent, setConsent] = useState<Consent>(DENY);71 const [decided, setDecided] = useState(false);72 const [open, setOpen] = useState(false);7374 useEffect(() => { // rehydrate the prior decision75 const raw = document.cookie.match(/(?:^|; )consent=([^;]+)/)?.[1];76 if (raw) { setConsent(JSON.parse(decodeURIComponent(raw))); setDecided(true); }77 }, []);7879 const save = (c: Consent) => {80 document.cookie = `consent=${encodeURIComponent(JSON.stringify(c))}; Max-Age=15552000; Path=/; SameSite=Lax`;81 setConsent(c); setDecided(true); setOpen(false); // 6-month record; re-ask, don't assume forever82 };83 return (84 <ConsentCtx.Provider value={{ consent, decided, save, reopen: () => setOpen(true) }}>85 {children}86 {(!decided || open) && <ConsentBanner />} {/* Accept + Reject share ONE button variant */}87 </ConsentCtx.Provider>88 );89}90```9192```tsx93// components/consent/consent-embed.tsx — the two-click gate for Maps/YouTube94"use client";95import { useConsent } from "./consent-provider";96export function ConsentEmbed({ label, children }: { label: string; children: React.ReactNode }) {97 const { consent, save } = useConsent();98 if (consent.embeds) return <>{children}</>; // the iframe mounts ONLY after opt-in99 return (100 <div className="grid place-items-center gap-3 rounded-[var(--radius-lg)] border bg-card p-8 text-center">101 <p className="text-sm text-muted-foreground">{label} lädt externe Inhalte von Dritten.</p>102 <button className="rounded-[var(--radius-md)] bg-primary px-4 py-2 text-primary-foreground"103 onClick={() => save({ ...consent, embeds: true })}>Inhalt laden</button>104 </div>105 );106}107```108109The `<ConsentBanner>` renders two buttons from the *same* `ultraweb:buttons` variant — not `primary` vs `ghost`. A gated tag is a leaf: `function Analytics(){ const {consent}=useConsent(); return consent.analytics ? <Script … /> : null; }`.110111## Embeds & third-party governance112113Generalize the Munich Google-Fonts ruling past fonts: the exposure is the *request*, not the cookie. Any origin your HTML reaches for — map tiles, a video frame, an avatar CDN, a chat widget — receives the visitor's IP and User-Agent before a single byte of storage is written, so the question is never "does it set a cookie" but "is the site allowed to contact this host at all". The cheapest answer is almost always deleting the origin rather than gating it.114115- **Maps render first-party by default.** MapLibre GL against an OSM/MapTiler source you control, or — for one café pin — a static map image plus a plain "In Google Maps öffnen" link. Neither contacts Google from the page, so neither needs a category, a placeholder, or a banner.116- **Google Maps only behind the click-to-load shim.** If the brief insists on the interactive embed, it renders through `ConsentEmbed` — branded placeholder in the site's tokens, real `<iframe>` mounted only after the visitor loads it. A "privacy-enhanced" host does not change this; gate the frame.117- **Video embeds are the same problem.** Self-host short clips via `ultraweb:media-optimization`; YouTube/Vimeo go through the identical shim, never an eager iframe with a play button drawn over it.118- **Analytics should stay out of this lane entirely** — `ultraweb:analytics` defaults to cookieless, which means no category, no shim, no banner. Only a cookie-based tool falls back into the gate above, and that is a reason to reconsider the tool.119120## Anti-patterns121122- **Unequal buttons** — filled/colored Accept beside a gray text-link or ghost Reject. Grep the banner: `rg -n 'variant="(ghost|link|outline|secondary)"' components/consent` — if the reject control's variant differs from accept's, it's a nudge.123- **Reject buried behind "Einstellungen"/"Mehr Optionen"** — a first layer with only Accept + Settings fails German enforcement; reject belongs on the first screen.124- **Pre-ticked categories** — `defaultChecked`, `checked={true}`, or a non-`false` default in `DENY`. Consent is affirmative.125- **Ungated third parties** — `rg -n '<iframe[^>]*src="https://(www\.youtube|www\.youtube-nocookie|player\.vimeo|www\.google\.com/maps)' -g "*.tsx"` outside a `ConsentEmbed`, or a third-party `<Script src="https://…">` with no `useConsent()` guard.126- **Google Fonts leak** — `rg -n "fonts\.(googleapis|gstatic)\.com"` (LG München); self-host via `next/font`, don't consent it away.127- **A drop-in CMP as "the design"** — `rg -ni "cookiebot|onetrust|usercentrics|cookieyes|iubenda"`; an un-restyleable gray overlay violates the "built in the site's own language" rule and adds its own data transfer.128- **No footer reopen link** — consent that can't be withdrawn as easily as given breaks Art. 7(3).129- **Loading on scroll/"implied consent"** — firing tags because the user kept browsing; §25 needs an affirmative act, not the absence of one.130131## Worked example — Kaffeewerk Ost, Berlin roastery shop + /abo (German-first, TTDSG applies directly)132133Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.134135## Composes with136137Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.