Skip to content
UI Kit
Accent color

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

4 variants

Variants

Details

What Consent preferences brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/consent-preferences.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputtextareaswitch
npm packages
None
Languages
English and German, 49 texts of its own

Props

The ConsentPreferencesProps interface from components/ui/consent-preferences.tsx, with the comments from the code.

  • variantoptional
    ConsentPreferencesVariant

    email-topicsunsubscribechannel-matrixprivacy-requests

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • emailoptional
    string

    The address the settings belong to, shown so people know which inbox

  • topicsoptional
    ConsentTopic[]
  • channelsoptional
    ConsentChannel[]
  • frequenciesoptional
    string[]

    email-topics: frequencies to choose from

  • answerTimeoptional
    string

    privacy-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
forEnglish Settings for {e}German Einstellungen für {e}
frequencyEnglish How often?German Wie oft?
pauseEnglish Pause all emails for 30 daysGerman Alle E-Mails 30 Tage pausieren
pauseTextEnglish We keep your choices and start again after the break.German Wir behalten deine Auswahl und starten nach der Pause wieder.
unsavedEnglish You changed your settingsGerman Du hast Einstellungen geändert
saveEnglish Save settingsGerman Einstellungen speichern
savedEnglish Saved. Your choices apply from the next email.German Gespeichert. Deine Auswahl gilt ab der nächsten E-Mail.
requiredEnglish Always onGerman Immer an
requiredTextEnglish Needed for your accountGerman Für dein Konto nötig
fewerEnglish Fewer emailsGerman Weniger E-Mails
fewerTextEnglish One summary a month instead of every newsGerman Eine Zusammenfassung im Monat statt jeder Neuigkeit
importantEnglish Only what mattersGerman Nur Wichtiges
importantTextEnglish Orders, invoices and security, nothing elseGerman Bestellungen, Rechnungen und Sicherheit, sonst nichts
noneEnglish Unsubscribe from allGerman Von allem abmelden
noneTextEnglish You can still sign in and see your ordersGerman Du kannst dich weiter anmelden und Bestellungen sehen
whyEnglish May we ask why?German Dürfen wir fragen, warum?
openCorrectEnglish CorrectGerman Berichtigen
optionalEnglish OptionalGerman Optional
openDeleteEnglish DeleteGerman Löschen
confirmEnglish ConfirmGerman Bestätigen
doneFewerEnglish From now on you get one summary a monthGerman Ab jetzt bekommst du eine Zusammenfassung im Monat
doneImportantEnglish From now on only what mattersGerman Ab jetzt nur noch Wichtiges
doneNoneEnglish You are unsubscribedGerman Du bist abgemeldet
doneTextEnglish 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}.
undoEnglish Undo and subscribe againGerman Rückgängig machen und wieder anmelden
topicEnglish TopicGerman Thema
channelOnEnglish {t} by {c}German {t} per {c}
allOnEnglish All onGerman Alle an
allOffEnglish All offGerman Alle aus
copyEnglish Get a copy of my dataGerman Kopie meiner Daten anfordern
copyTextEnglish 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.
copyDoneEnglish 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.
correctEnglish Correct my dataGerman Meine Daten berichtigen
correctTextEnglish Tell us what is wrong, we fix it and confirm.German Sag uns, was nicht stimmt, wir korrigieren es und bestätigen.
correctLabelEnglish What should we correct?German Was sollen wir berichtigen?
correctNeededEnglish Please tell us what is wrong, so we know what to fix.German Bitte schreib, was nicht stimmt, damit wir wissen, was wir korrigieren.
correctDoneEnglish Thank you, we correct it and confirm by email.German Danke, wir korrigieren es und bestätigen per E-Mail.
objectEnglish Stop using my data for marketingGerman Meine Daten nicht mehr für Werbung nutzen
objectTextEnglish No more offers or personal suggestions, your account stays.German Keine Angebote und persönlichen Vorschläge mehr, dein Konto bleibt.
objectDoneEnglish Done. We no longer use your data for marketing.German Erledigt. Wir nutzen deine Daten nicht mehr für Werbung.
delEnglish Delete my accountGerman Mein Konto löschen
delTextEnglish 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.
delConfirmEnglish Type {w} to confirmGerman Tippe {w} zur Bestätigung
delWordEnglish DELETEGerman LÖSCHEN
delNeededEnglish 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.
delDoneEnglish 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.
requestEnglish Send requestGerman Anfrage senden
answerEnglish We answer {t}German Wir antworten {t}
sentEnglish Request sentGerman 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
components/ui/consent-preferences.tsx
'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.

Often searched

Jump to

Copied to the clipboard