Consent preferences
The page people come back to from every email or their account. Topics and frequency with a save bar that appears on change, unsubscribing with a softer option first, topics against channels, and requests about their own data.
One of 3 components in Address, payment and consent
Variants
Details
What Consent preferences brings along, read from the registry and the source.
- Category
- Address, payment and consent
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/consent-preferences.tsx
- npm packages
- None
- Languages
- English and German, 49 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ConsentPreferencesProps interface from components/ui/consent-preferences.tsx, with the comments from the code.
variantoptionalConsentPreferencesVariantemail-topicsunsubscribechannel-matrixprivacy-requests
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeemailoptionalstringThe address the settings belong to, shown so people know which inbox
topicsoptionalConsentTopic[]channelsoptionalConsentChannel[]frequenciesoptionalstring[]email-topics: frequencies to choose from
answerTimeoptionalstringprivacy-requests: who answers and how fast, e.g. "within 30 days"
onSaveoptional(data: Record<string, unknown>) => void
Built-in texts
Consent preferences writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 49 texts
| Key | English | German |
|---|---|---|
| for | English Settings for {e} | German Einstellungen für {e} |
| frequency | English How often? | German Wie oft? |
| pause | English Pause all emails for 30 days | German Alle E-Mails 30 Tage pausieren |
| pauseText | English We keep your choices and start again after the break. | German Wir behalten deine Auswahl und starten nach der Pause wieder. |
| unsaved | English You changed your settings | German Du hast Einstellungen geändert |
| save | English Save settings | German Einstellungen speichern |
| saved | English Saved. Your choices apply from the next email. | German Gespeichert. Deine Auswahl gilt ab der nächsten E-Mail. |
| required | English Always on | German Immer an |
| requiredText | English Needed for your account | German Für dein Konto nötig |
| fewer | English Fewer emails | German Weniger E-Mails |
| fewerText | English One summary a month instead of every news | German Eine Zusammenfassung im Monat statt jeder Neuigkeit |
| important | English Only what matters | German Nur Wichtiges |
| importantText | English Orders, invoices and security, nothing else | German Bestellungen, Rechnungen und Sicherheit, sonst nichts |
| none | English Unsubscribe from all | German Von allem abmelden |
| noneText | English You can still sign in and see your orders | German Du kannst dich weiter anmelden und Bestellungen sehen |
| why | English May we ask why? | German Dürfen wir fragen, warum? |
| openCorrect | English Correct | German Berichtigen |
| optional | English Optional | German Optional |
| openDelete | English Delete | German Löschen |
| confirm | English Confirm | German Bestätigen |
| doneFewer | English From now on you get one summary a month | German Ab jetzt bekommst du eine Zusammenfassung im Monat |
| doneImportant | English From now on only what matters | German Ab jetzt nur noch Wichtiges |
| doneNone | English You are unsubscribed | German Du bist abgemeldet |
| doneText | English The change applies within 24 hours. A confirmation is on its way to {e}. | German Die Änderung gilt innerhalb von 24 Stunden. Eine Bestätigung ist unterwegs an {e}. |
| undo | English Undo and subscribe again | German Rückgängig machen und wieder anmelden |
| topic | English Topic | German Thema |
| channelOn | English {t} by {c} | German {t} per {c} |
| allOn | English All on | German Alle an |
| allOff | English All off | German Alle aus |
| copy | English Get a copy of my data | German Kopie meiner Daten anfordern |
| copyText | English Everything we store about you, as a file you can open anywhere. | German Alles, was wir über dich speichern, als Datei, die du überall öffnen kannst. |
| copyDone | English We are preparing your file. You get a download link by email. | German Wir bereiten deine Datei vor. Den Link zum Herunterladen bekommst du per E-Mail. |
| correct | English Correct my data | German Meine Daten berichtigen |
| correctText | English Tell us what is wrong, we fix it and confirm. | German Sag uns, was nicht stimmt, wir korrigieren es und bestätigen. |
| correctLabel | English What should we correct? | German Was sollen wir berichtigen? |
| correctNeeded | English Please tell us what is wrong, so we know what to fix. | German Bitte schreib, was nicht stimmt, damit wir wissen, was wir korrigieren. |
| correctDone | English Thank you, we correct it and confirm by email. | German Danke, wir korrigieren es und bestätigen per E-Mail. |
| object | English Stop using my data for marketing | German Meine Daten nicht mehr für Werbung nutzen |
| objectText | English No more offers or personal suggestions, your account stays. | German Keine Angebote und persönlichen Vorschläge mehr, dein Konto bleibt. |
| objectDone | English Done. We no longer use your data for marketing. | German Erledigt. Wir nutzen deine Daten nicht mehr für Werbung. |
| del | English Delete my account | German Mein Konto löschen |
| delText | English Your account and data go for good. Invoices stay as long as the law asks. | German Konto und Daten verschwinden endgültig. Rechnungen bleiben, solange das Gesetz es verlangt. |
| delConfirm | English Type {w} to confirm | German Tippe {w} zur Bestätigung |
| delWord | English DELETE | German LÖSCHEN |
| delNeeded | English Please type {w} in capitals, so nothing is deleted by accident. | German Bitte tippe {w} in Großbuchstaben, damit nichts aus Versehen gelöscht wird. |
| delDone | English Your account will be deleted in 14 days. Until then you can change your mind in the email we sent. | German Dein Konto wird in 14 Tagen gelöscht. Bis dahin kannst du es dir über die E-Mail noch anders überlegen. |
| request | English Send request | German Anfrage senden |
| answer | English We answer {t} | German Wir antworten {t} |
| sent | English Request sent | German Anfrage gesendet |
Accessibility
Read from the source of the component.
- Consent preferences handles the keys Delete itself.
- Sets aria-controls, aria-expanded, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 628 lines
'use client';
import { useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DoneMark, FlowHead, Unfold } from '@/components/ui/form-flow';
/*
* What a person allows: which emails, on which channel, how often, and what
* happens with their data. Cookie consent lives in popups, this is the page
* people come back to, from the link in every email or from their account.
*
* email-topics topics as switches, a frequency, a pause, a save bar that appears on change
* unsubscribe fewer emails, only important ones or none, with a way back
* channel-matrix topics against channels (email, text, push, post), cards on phones
* privacy-requests copy, correct, object or delete, each a request with a clear outcome
*/
export type ConsentPreferencesVariant = 'email-topics' | 'unsubscribe' | 'channel-matrix' | 'privacy-requests';
export interface ConsentTopic {
id: string;
title: string;
text?: string;
icon?: IconName;
/** On at the start */
on?: boolean;
/** Required messages, shown but not switchable (e.g. invoices) */
locked?: boolean;
/** channel-matrix: channels that are on, by channel id */
channels?: string[];
}
export interface ConsentChannel {
id: string;
label: string;
icon?: IconName;
}
export interface ConsentPreferencesProps {
variant?: ConsentPreferencesVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** The address the settings belong to, shown so people know which inbox */
email?: string;
topics?: ConsentTopic[];
channels?: ConsentChannel[];
/** email-topics: frequencies to choose from */
frequencies?: string[];
/** privacy-requests: who answers and how fast, e.g. "within 30 days" */
answerTime?: string;
onSave?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
for: 'Settings for {e}',
frequency: 'How often?',
pause: 'Pause all emails for 30 days',
pauseText: 'We keep your choices and start again after the break.',
unsaved: 'You changed your settings',
save: 'Save settings',
saved: 'Saved. Your choices apply from the next email.',
required: 'Always on',
requiredText: 'Needed for your account',
fewer: 'Fewer emails',
fewerText: 'One summary a month instead of every news',
important: 'Only what matters',
importantText: 'Orders, invoices and security, nothing else',
none: 'Unsubscribe from all',
noneText: 'You can still sign in and see your orders',
why: 'May we ask why?',
openCorrect: 'Correct',
optional: 'Optional',
openDelete: 'Delete',
confirm: 'Confirm',
doneFewer: 'From now on you get one summary a month',
doneImportant: 'From now on only what matters',
doneNone: 'You are unsubscribed',
doneText: 'The change applies within 24 hours. A confirmation is on its way to {e}.',
undo: 'Undo and subscribe again',
topic: 'Topic',
channelOn: '{t} by {c}',
allOn: 'All on',
allOff: 'All off',
copy: 'Get a copy of my data',
copyText: 'Everything we store about you, as a file you can open anywhere.',
copyDone: 'We are preparing your file. You get a download link by email.',
correct: 'Correct my data',
correctText: 'Tell us what is wrong, we fix it and confirm.',
correctLabel: 'What should we correct?',
correctNeeded: 'Please tell us what is wrong, so we know what to fix.',
correctDone: 'Thank you, we correct it and confirm by email.',
object: 'Stop using my data for marketing',
objectText: 'No more offers or personal suggestions, your account stays.',
objectDone: 'Done. We no longer use your data for marketing.',
del: 'Delete my account',
delText: 'Your account and data go for good. Invoices stay as long as the law asks.',
delConfirm: 'Type {w} to confirm',
delWord: 'DELETE',
delNeeded: 'Please type {w} in capitals, so nothing is deleted by accident.',
delDone: 'Your account will be deleted in 14 days. Until then you can change your mind in the email we sent.',
request: 'Send request',
answer: 'We answer {t}',
sent: 'Request sent',
},
de: {
for: 'Einstellungen für {e}',
frequency: 'Wie oft?',
pause: 'Alle E-Mails 30 Tage pausieren',
pauseText: 'Wir behalten deine Auswahl und starten nach der Pause wieder.',
unsaved: 'Du hast Einstellungen geändert',
save: 'Einstellungen speichern',
saved: 'Gespeichert. Deine Auswahl gilt ab der nächsten E-Mail.',
required: 'Immer an',
requiredText: 'Für dein Konto nötig',
fewer: 'Weniger E-Mails',
fewerText: 'Eine Zusammenfassung im Monat statt jeder Neuigkeit',
important: 'Nur Wichtiges',
importantText: 'Bestellungen, Rechnungen und Sicherheit, sonst nichts',
none: 'Von allem abmelden',
noneText: 'Du kannst dich weiter anmelden und Bestellungen sehen',
why: 'Dürfen wir fragen, warum?',
openCorrect: 'Berichtigen',
optional: 'Optional',
openDelete: 'Löschen',
confirm: 'Bestätigen',
doneFewer: 'Ab jetzt bekommst du eine Zusammenfassung im Monat',
doneImportant: 'Ab jetzt nur noch Wichtiges',
doneNone: 'Du bist abgemeldet',
doneText: 'Die Änderung gilt innerhalb von 24 Stunden. Eine Bestätigung ist unterwegs an {e}.',
undo: 'Rückgängig machen und wieder anmelden',
topic: 'Thema',
channelOn: '{t} per {c}',
allOn: 'Alle an',
allOff: 'Alle aus',
copy: 'Kopie meiner Daten anfordern',
copyText: 'Alles, was wir über dich speichern, als Datei, die du überall öffnen kannst.',
copyDone: 'Wir bereiten deine Datei vor. Den Link zum Herunterladen bekommst du per E-Mail.',
correct: 'Meine Daten berichtigen',
correctText: 'Sag uns, was nicht stimmt, wir korrigieren es und bestätigen.',
correctLabel: 'Was sollen wir berichtigen?',
correctNeeded: 'Bitte schreib, was nicht stimmt, damit wir wissen, was wir korrigieren.',
correctDone: 'Danke, wir korrigieren es und bestätigen per E-Mail.',
object: 'Meine Daten nicht mehr für Werbung nutzen',
objectText: 'Keine Angebote und persönlichen Vorschläge mehr, dein Konto bleibt.',
objectDone: 'Erledigt. Wir nutzen deine Daten nicht mehr für Werbung.',
del: 'Mein Konto löschen',
delText: 'Konto und Daten verschwinden endgültig. Rechnungen bleiben, solange das Gesetz es verlangt.',
delConfirm: 'Tippe {w} zur Bestätigung',
delWord: 'LÖSCHEN',
delNeeded: 'Bitte tippe {w} in Großbuchstaben, damit nichts aus Versehen gelöscht wird.',
delDone: 'Dein Konto wird in 14 Tagen gelöscht. Bis dahin kannst du es dir über die E-Mail noch anders überlegen.',
request: 'Anfrage senden',
answer: 'Wir antworten {t}',
sent: 'Anfrage gesendet',
},
};
type Texts = (typeof TEXTS)['en'];
const REASONS = { en: ['Too many emails', 'Not relevant for me', 'I never signed up', 'Something else'], de: ['Zu viele E-Mails', 'Für mich nicht relevant', 'Nie angemeldet', 'Etwas anderes'] };
function Address({ email, t }: { email?: string; t: Texts }) {
if (!email) return null;
return (
<p className="flex items-center gap-2 text-[13.5px] text-muted-foreground">
<Icon name="mail" size={15} className="shrink-0" />
{t.for.replace('{e}', email)}
</p>
);
}
/* ------------------------------------------------------------------ */
/* email-topics */
/* ------------------------------------------------------------------ */
function EmailTopics({ p, t }: { p: ConsentPreferencesProps; t: Texts }) {
const topics = p.topics ?? [];
const freqs = p.frequencies ?? [];
const start = { on: Object.fromEntries(topics.map((x) => [x.id, Boolean(x.on || x.locked)])), freq: freqs[0] ?? '', pause: false };
const [on, setOn] = useState<Record<string, boolean>>(start.on);
const [freq, setFreq] = useState(start.freq);
const [pause, setPause] = useState(false);
const [base, setBase] = useState(JSON.stringify(start));
const [savedNote, setSavedNote] = useState(false);
const id = useId();
const dirty = JSON.stringify({ on, freq, pause }) !== base;
return (
<Reveal effect="rise" className="mx-auto max-w-[44rem]">
<div className="kit-card overflow-hidden">
<div className="border-b border-border px-5 py-4 sm:px-6">
<Address email={p.email} t={t} />
</div>
<Stagger step={60} as="ul" className={cn('divide-y divide-border transition-opacity duration-500', pause && 'opacity-50')}>
{topics.map((x) => (
<li key={x.id} className="px-5 py-4 sm:px-6">
{x.locked ? (
<div className="flex items-center gap-3.5">
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{x.title}</span>
<span className="block text-[13px] text-muted-foreground">{x.text ?? t.requiredText}</span>
</span>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
<Icon name="lock" size={12} />
{t.required}
</span>
</div>
) : (
<Switch label={x.title} description={x.text} icon={x.icon} variant="default" checked={on[x.id]} disabled={pause} onCheckedChange={(v) => (setOn((o) => ({ ...o, [x.id]: v })), setSavedNote(false))} />
)}
</li>
))}
</Stagger>
{freqs.length > 0 && (
<fieldset className="border-t border-border px-5 py-5 sm:px-6">
<legend className="sr-only">{t.frequency}</legend>
<p aria-hidden className="text-sm font-medium text-foreground">
{t.frequency}
</p>
{/* A segmented choice with a sliding tile */}
<div className="relative mt-3 grid rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1" style={{ gridTemplateColumns: `repeat(${freqs.length}, minmax(0, 1fr))` }}>
<span aria-hidden className="absolute inset-y-1 start-1 rounded-md bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none" style={{ width: `calc(${Math.round((100 / freqs.length) * 1000) / 1000}% - ${Math.round((8 / freqs.length) * 100) / 100}px)`, translate: `calc(${Math.max(0, freqs.indexOf(freq))} * 100%)` }} />
{freqs.map((x, i) => (
<label key={x} htmlFor={`${id}-f${i}`} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center rounded-md px-2 text-center text-[13.5px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] sm:min-h-10">
<input id={`${id}-f${i}`} type="radio" name={`${id}-freq`} checked={freq === x} disabled={pause} onChange={() => (setFreq(x), setSavedNote(false))} className="sr-only" />
{x}
</label>
))}
</div>
</fieldset>
)}
<div className="border-t border-border bg-surface px-5 py-4 sm:px-6">
<Switch label={t.pause} description={t.pauseText} icon="pause" variant="default" checked={pause} onCheckedChange={(v) => (setPause(v), setSavedNote(false))} />
</div>
</div>
{/* The save bar rises in only when something changed and stays at the bottom of the screen */}
<div className="sticky bottom-3 z-20 mt-4" aria-live="polite">
{dirty ? (
<div className="kit-rise flex items-center justify-between gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--card)_90%,transparent)] p-2 ps-4 shadow-[var(--shadow-lg)] backdrop-blur-md">
<span className="flex min-w-0 items-center gap-2 text-[13.5px] text-foreground">
<span className="size-2 shrink-0 rounded-full bg-primary" />
<span className="truncate">{t.unsaved}</span>
</span>
<Button
size="sm"
icon="check"
onClick={() => {
p.onSave?.({ topics: on, frequency: freq, pause });
setBase(JSON.stringify({ on, freq, pause }));
setSavedNote(true);
}}
>
{t.save}
</Button>
</div>
) : savedNote ? (
<p role="status" className="kit-rise flex items-center justify-center gap-2 text-[13.5px] text-success-foreground">
<Icon name="check-circle" size={16} />
{t.saved}
</p>
) : null}
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* unsubscribe */
/* ------------------------------------------------------------------ */
function Unsubscribe({ p, t, reasons }: { p: ConsentPreferencesProps; t: Texts; reasons: string[] }) {
const id = useId();
const [choice, setChoice] = useState<'fewer' | 'important' | 'none'>('fewer');
const [reason, setReason] = useState('');
const [done, setDone] = useState(false);
const head = useRef<HTMLParagraphElement>(null);
const options: { v: typeof choice; icon: IconName; title: string; text: string }[] = [
{ v: 'fewer', icon: 'calendar', title: t.fewer, text: t.fewerText },
{ v: 'important', icon: 'shield', title: t.important, text: t.importantText },
{ v: 'none', icon: 'bell', title: t.none, text: t.noneText },
];
if (done)
return (
<div role="status" className="kit-card kit-rise mx-auto flex max-w-[38rem] flex-col items-center p-8 text-center sm:p-10">
<DoneMark size={56} />
<p ref={head} tabIndex={-1} className="mt-5 text-balance text-[21px] font-semibold tracking-[-0.02em] text-foreground outline-none">
{choice === 'fewer' ? t.doneFewer : choice === 'important' ? t.doneImportant : t.doneNone}
</p>
<p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{t.doneText.replace('{e}', p.email ?? '')}</p>
<Button variant="outline" iconLeft="undo" className="mt-6" onClick={() => setDone(false)}>
{t.undo}
</Button>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card grid gap-6 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
p.onSave?.({ choice, reason });
setDone(true);
requestAnimationFrame(() => head.current?.focus());
}}
>
<Address email={p.email} t={t} />
<div>
<fieldset className="min-w-0">
<legend className="sr-only">{t.fewer}</legend>
<Stagger step={80} as="div" className="grid gap-2.5">
{options.map((o) => {
const on = choice === o.v;
const destructive = o.v === 'none';
return (
<label key={o.v} className={cn('group/o flex cursor-pointer items-center gap-4 rounded-xl border bg-card p-4 transition-[border-color,box-shadow,background-color] duration-300 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', on ? (destructive ? 'border-[color-mix(in_oklab,var(--destructive)_55%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_5%,var(--card))]' : 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_50%,var(--border))]') : 'border-border hover:border-border-strong')}>
<input type="radio" name={`${id}-c`} checked={on} onChange={() => setChoice(o.v)} className="sr-only" />
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl transition-colors duration-300', on ? (destructive ? 'bg-[color-mix(in_oklab,var(--destructive)_14%,var(--card))] text-destructive' : 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]') : 'bg-surface text-muted-foreground')}>
<Icon name={o.icon} size={19} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-medium text-foreground">{o.title}</span>
<span className="mt-0.5 block text-[13px] leading-snug text-muted-foreground">{o.text}</span>
</span>
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? (destructive ? 'border-destructive bg-destructive' : 'border-primary bg-primary') : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
<span className={cn('size-2 rounded-full bg-background transition-[scale] duration-300', on ? 'scale-100' : 'scale-0')} />
</span>
</label>
);
})}
</Stagger>
</fieldset>
{/* The reason opens only for a full unsubscribe, it is optional */}
<Unfold open={choice === 'none'}>
<div className="pt-6">
<fieldset className="min-w-0">
<legend className="text-sm font-medium text-foreground">
{t.why} <span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>
</legend>
<div className="mt-3 flex flex-wrap gap-2">
{reasons.map((r) => (
<label key={r} className="flex min-h-11 cursor-pointer items-center rounded-lg border border-border bg-card px-3.5 text-[13.5px] text-foreground transition-[border-color,background-color] has-[:checked]:border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] has-[:checked]:bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:min-h-10">
<input type="radio" name={`${id}-r`} checked={reason === r} onChange={() => setReason(r)} className="sr-only" />
{r}
</label>
))}
</div>
</fieldset>
</div>
</Unfold>
</div>
<Button type="submit" variant={choice === 'none' ? 'destructive' : 'primary'} icon="check" className="sm:justify-self-start max-sm:w-full">
{choice === 'none' ? t.none : t.confirm}
</Button>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* channel-matrix */
/* ------------------------------------------------------------------ */
/** One cell of the matrix: a real checkbox drawn by the kit, outside the parent so focus survives a toggle */
function Tick({ on, onToggle, label, locked }: { on: boolean; onToggle: () => void; label: string; locked?: boolean }) {
return (
<label className={cn('group/tick grid size-11 cursor-pointer place-items-center rounded-lg has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', locked && 'cursor-not-allowed')}>
<input type="checkbox" checked={on} disabled={locked} onChange={onToggle} aria-label={label} className="peer sr-only" />
<span aria-hidden className={cn('grid size-[22px] place-items-center rounded-md border transition-[background-color,border-color,scale] duration-300 ease-[cubic-bezier(0.34,1.6,0.64,1)]', on ? 'scale-105 border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))] bg-background group-hover/tick:border-foreground', locked && 'opacity-50')}>
<svg viewBox="0 0 24 24" width={14} height={14} fill="none" className={cn('text-primary-foreground transition-opacity', on ? 'opacity-100' : 'opacity-0')}>
<path d="m5 12.5 4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</label>
);
}
function ChannelMatrix({ p, t }: { p: ConsentPreferencesProps; t: Texts }) {
const topics = p.topics ?? [];
const channels = p.channels ?? [];
const start = Object.fromEntries(topics.map((x) => [x.id, x.channels ?? []]));
const [state, setState] = useState<Record<string, string[]>>(start);
const [base, setBase] = useState(JSON.stringify(start));
const [savedNote, setSavedNote] = useState(false);
const dirty = JSON.stringify(state) !== base;
const toggle = (topic: string, ch: string) => {
setSavedNote(false);
setState((s) => {
const cur = s[topic] ?? [];
return { ...s, [topic]: cur.includes(ch) ? cur.filter((x) => x !== ch) : [...cur, ch] };
});
};
const column = (ch: string, value: boolean) => {
setSavedNote(false);
setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, topics.find((x) => x.id === k)?.locked ? v : value ? [...new Set([...v, ch])] : v.filter((x) => x !== ch)])));
};
return (
<Reveal effect="rise" className="mx-auto max-w-[52rem]">
<div className="kit-card overflow-hidden">
<div className="border-b border-border px-5 py-4 sm:px-6">
<Address email={p.email} t={t} />
</div>
{/* Tablet and desktop: a table with a switch for each column */}
<table className="hidden w-full border-collapse sm:table">
<thead>
<tr className="border-b border-border bg-surface">
<th scope="col" className="px-6 py-3 text-start text-[12.5px] font-medium text-muted-foreground">
{t.topic}
</th>
{channels.map((c) => {
const allOn = topics.filter((x) => !x.locked).every((x) => (state[x.id] ?? []).includes(c.id));
return (
<th key={c.id} scope="col" className="w-24 px-2 py-3 text-center">
<span className="flex flex-col items-center gap-1">
<span className="flex items-center gap-1.5 text-[13px] font-medium text-foreground">
{c.icon && <Icon name={c.icon} size={15} className="text-muted-foreground" />}
{c.label}
</span>
<button type="button" onClick={() => column(c.id, !allOn)} className="min-h-11 rounded-md px-1.5 text-[11.5px] font-medium lg:min-h-8 text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
{allOn ? t.allOff : t.allOn}
</button>
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{topics.map((x) => (
<tr key={x.id} className="border-b border-border transition-colors last:border-0 hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]">
<th scope="row" className="px-6 py-3 text-start font-normal">
<span className="block text-[14.5px] font-medium text-foreground">{x.title}</span>
{x.text && <span className="block text-[13px] text-muted-foreground">{x.text}</span>}
</th>
{channels.map((c) => (
<td key={c.id} className="px-2 py-1 text-center">
<span className="inline-grid">
<Tick on={(state[x.id] ?? []).includes(c.id)} onToggle={() => toggle(x.id, c.id)} label={t.channelOn.replace('{t}', x.title).replace('{c}', c.label)} locked={x.locked} />
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Phones: one card per topic with the channels as a row of chips */}
<ul className="divide-y divide-border sm:hidden">
{topics.map((x) => (
<li key={x.id} className="px-5 py-4">
<p className="text-[15px] font-medium text-foreground">{x.title}</p>
{x.text && <p className="text-[13px] text-muted-foreground">{x.text}</p>}
<div className="mt-3 grid grid-cols-2 gap-2">
{channels.map((c) => {
const on = (state[x.id] ?? []).includes(c.id);
return (
<label key={c.id} className={cn('flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-3 text-[13.5px] transition-[border-color,background-color] has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground', x.locked && 'cursor-not-allowed opacity-60')}>
<input type="checkbox" checked={on} disabled={x.locked} onChange={() => toggle(x.id, c.id)} aria-label={t.channelOn.replace('{t}', x.title).replace('{c}', c.label)} className="sr-only" />
{c.icon && <Icon name={c.icon} size={15} />}
{c.label}
{on && <Icon name="check" size={14} strokeWidth={2.4} className="ms-auto text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />}
</label>
);
})}
</div>
</li>
))}
</ul>
<div className="flex flex-col gap-3 border-t border-border bg-surface px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6" aria-live="polite">
<p className={cn('text-[13.5px]', savedNote ? 'text-success-foreground' : 'text-muted-foreground')}>{savedNote ? t.saved : dirty ? t.unsaved : ''}</p>
<Button
icon="check"
disabled={!dirty}
onClick={() => {
p.onSave?.({ channels: state });
setBase(JSON.stringify(state));
setSavedNote(true);
}}
className="max-sm:w-full"
>
{t.save}
</Button>
</div>
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* privacy-requests */
/* ------------------------------------------------------------------ */
type RequestKind = 'copy' | 'correct' | 'object' | 'delete';
function PrivacyRequests({ p, t }: { p: ConsentPreferencesProps; t: Texts }) {
const id = useId();
const [open, setOpen] = useState<RequestKind | null>(null);
const [sent, setSent] = useState<Partial<Record<RequestKind, boolean>>>({});
const [correction, setCorrection] = useState('');
const [word, setWord] = useState('');
const [error, setError] = useState('');
const items: { k: RequestKind; icon: IconName; title: string; text: string; done: string }[] = [
{ k: 'copy', icon: 'download', title: t.copy, text: t.copyText, done: t.copyDone },
{ k: 'correct', icon: 'edit', title: t.correct, text: t.correctText, done: t.correctDone },
{ k: 'object', icon: 'ban', title: t.object, text: t.objectText, done: t.objectDone },
{ k: 'delete', icon: 'trash', title: t.del, text: t.delText, done: t.delDone },
];
const send = (k: RequestKind) => {
if (k === 'correct' && !correction.trim()) {
setError(t.correctNeeded);
requestAnimationFrame(() => document.getElementById(`${id}-correct`)?.focus());
return;
}
if (k === 'delete' && word.trim() !== t.delWord) {
setError(t.delNeeded.replace('{w}', t.delWord));
requestAnimationFrame(() => document.getElementById(`${id}-word`)?.focus());
return;
}
setError('');
p.onSave?.({ request: k, correction: k === 'correct' ? correction : undefined });
setSent((s) => ({ ...s, [k]: true }));
setOpen(null);
};
return (
<div className="mx-auto grid max-w-[46rem] gap-4">
<Reveal effect="rise" className="flex flex-wrap items-center justify-between gap-3">
<Address email={p.email} t={t} />
{p.answerTime && (
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="clock" size={15} />
{t.answer.replace('{t}', p.answerTime)}
</p>
)}
</Reveal>
<Stagger step={80} as="ul" className="grid gap-3">
{items.map((it) => {
const isOpen = open === it.k;
const done = sent[it.k];
const danger = it.k === 'delete';
return (
<li key={it.k} className={cn('kit-card overflow-hidden transition-[border-color,box-shadow] duration-300', isOpen && (danger ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]'))}>
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
<span className={cn('grid size-11 shrink-0 place-items-center rounded-xl max-sm:hidden', danger ? 'bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive' : 'bg-surface text-muted-foreground')}>
<Icon name={done ? 'check' : it.icon} size={20} />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-[15.5px] font-semibold text-foreground">{it.title}</h3>
<p className={cn('mt-0.5 text-pretty text-[13.5px] leading-snug', done ? 'text-success-foreground' : 'text-muted-foreground')} aria-live="polite">
{done ? it.done : it.text}
</p>
</div>
{done ? (
<span className="inline-flex h-9 shrink-0 items-center gap-1.5 self-start rounded-full bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] px-3 text-[13px] font-medium text-success-foreground sm:self-center">
<Icon name="check-circle" size={15} />
{t.sent}
</span>
) : it.k === 'copy' || it.k === 'object' ? (
<Button size="sm" variant="outline" onClick={() => send(it.k)} className="max-sm:w-full">
{t.request}
</Button>
) : (
<Button size="sm" variant="outline" aria-expanded={isOpen} aria-controls={`${id}-${it.k}-panel`} icon={isOpen ? 'chevron-up' : 'chevron-down'} onClick={() => (setOpen(isOpen ? null : it.k), setError(''))} className={cn('max-sm:w-full', danger && 'text-destructive')}>
{danger ? t.openDelete : t.openCorrect}
</Button>
)}
</div>
{(it.k === 'correct' || it.k === 'delete') && !done && (
<Unfold open={isOpen}>
<form
id={`${id}-${it.k}-panel`}
noValidate
onSubmit={(e) => {
e.preventDefault();
send(it.k);
}}
className="grid gap-4 border-t border-border bg-surface p-5 sm:p-6"
>
{it.k === 'correct' ? (
<Textarea id={`${id}-correct`} label={t.correctLabel} value={correction} onChange={(e) => (setCorrection(e.target.value), setError(''))} error={error || undefined} minRows={3} maxLength={800} />
) : (
<Input id={`${id}-word`} label={t.delConfirm.replace('{w}', t.delWord)} autoComplete="off" value={word} onChange={(e) => (setWord(e.target.value), setError(''))} error={error || undefined} className="sm:max-w-[20rem]" />
)}
<Button type="submit" variant={danger ? 'destructive' : 'primary'} icon={danger ? 'trash' : 'send'} className="sm:justify-self-start max-sm:w-full">
{danger ? t.del : t.request}
</Button>
</form>
</Unfold>
)}
</li>
);
})}
</Stagger>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* The page where people decide what they receive and what happens with their data.
* <ConsentPreferences variant="email-topics" email="lena@example.com" topics={[{ id, title, text, on, locked }]} frequencies={['Weekly', 'Monthly']} />
*/
export function ConsentPreferences(props: ConsentPreferencesProps) {
const { variant = 'email-topics', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'email-topics' && <EmailTopics p={props} t={t} />}
{variant === 'unsubscribe' && <Unsubscribe p={props} t={t} reasons={REASONS[locale]} />}
{variant === 'channel-matrix' && <ChannelMatrix p={props} t={t} />}
{variant === 'privacy-requests' && <PrivacyRequests p={props} t={t} />}
</Section>
);
}Frequent questions
How do I install Consent preferences?
With aiw add @ai-website/consent-preferences. The command puts components/ui/consent-preferences.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input, textarea and switch. It needs no further npm packages.
Which variants does Consent preferences have?
4 variants, Email topics, Unsubscribe, Channels and Privacy requests. You pick one with the variant prop, for example variant="unsubscribe".
Can I translate the texts of Consent preferences?
Yes. Consent preferences carries 49 texts of its own in English and German, such as “How often?”, “Pause all emails for 30 days” and “We keep your choices and start again after the break.”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Consent preferences?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Consent preferences with aiw_components and install it with aiw_add @ai-website/consent-preferences. Name the variant in your prompt, for example variant="email-topics", and the assistant writes your content straight into the props.
Can I adjust Consent preferences without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ConsentPreferences code to copy.