Zum Inhalt springen
UI Kit
Accent

Einstellungen und Einwilligungen

Die Seite, auf die Menschen aus jeder E-Mail oder ihrem Konto zurückkommen. Themen und Häufigkeit mit Speicherleiste, die bei Änderungen erscheint, Abmelden mit sanfterer Option zuerst, Themen gegen Kanäle und Anfragen zu den eigenen Daten.

Eine von 3 Components in Adresse, Zahlung und Einwilligung

4 Variants

Variants

Details

Was Einstellungen und Einwilligungen mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/consent-preferences.tsx
Bringt mit
utilskitbuttoniconrevealsectionmotionform-flowinputtextareaswitch
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 49 eigene Texte

Props

Die Schnittstelle ConsentPreferencesProps aus components/ui/consent-preferences.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Einstellungen und Einwilligungen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 49 Texte anzeigen
forEnglisch Settings for {e}Deutsch Einstellungen für {e}
frequencyEnglisch How often?Deutsch Wie oft?
pauseEnglisch Pause all emails for 30 daysDeutsch Alle E-Mails 30 Tage pausieren
pauseTextEnglisch We keep your choices and start again after the break.Deutsch Wir behalten deine Auswahl und starten nach der Pause wieder.
unsavedEnglisch You changed your settingsDeutsch Du hast Einstellungen geändert
saveEnglisch Save settingsDeutsch Einstellungen speichern
savedEnglisch Saved. Your choices apply from the next email.Deutsch Gespeichert. Deine Auswahl gilt ab der nächsten E-Mail.
requiredEnglisch Always onDeutsch Immer an
requiredTextEnglisch Needed for your accountDeutsch Für dein Konto nötig
fewerEnglisch Fewer emailsDeutsch Weniger E-Mails
fewerTextEnglisch One summary a month instead of every newsDeutsch Eine Zusammenfassung im Monat statt jeder Neuigkeit
importantEnglisch Only what mattersDeutsch Nur Wichtiges
importantTextEnglisch Orders, invoices and security, nothing elseDeutsch Bestellungen, Rechnungen und Sicherheit, sonst nichts
noneEnglisch Unsubscribe from allDeutsch Von allem abmelden
noneTextEnglisch You can still sign in and see your ordersDeutsch Du kannst dich weiter anmelden und Bestellungen sehen
whyEnglisch May we ask why?Deutsch Dürfen wir fragen, warum?
openCorrectEnglisch CorrectDeutsch Berichtigen
optionalEnglisch OptionalDeutsch Optional
openDeleteEnglisch DeleteDeutsch Löschen
confirmEnglisch ConfirmDeutsch Bestätigen
doneFewerEnglisch From now on you get one summary a monthDeutsch Ab jetzt bekommst du eine Zusammenfassung im Monat
doneImportantEnglisch From now on only what mattersDeutsch Ab jetzt nur noch Wichtiges
doneNoneEnglisch You are unsubscribedDeutsch Du bist abgemeldet
doneTextEnglisch The change applies within 24 hours. A confirmation is on its way to {e}.Deutsch Die Änderung gilt innerhalb von 24 Stunden. Eine Bestätigung ist unterwegs an {e}.
undoEnglisch Undo and subscribe againDeutsch Rückgängig machen und wieder anmelden
topicEnglisch TopicDeutsch Thema
channelOnEnglisch {t} by {c}Deutsch {t} per {c}
allOnEnglisch All onDeutsch Alle an
allOffEnglisch All offDeutsch Alle aus
copyEnglisch Get a copy of my dataDeutsch Kopie meiner Daten anfordern
copyTextEnglisch Everything we store about you, as a file you can open anywhere.Deutsch Alles, was wir über dich speichern, als Datei, die du überall öffnen kannst.
copyDoneEnglisch We are preparing your file. You get a download link by email.Deutsch Wir bereiten deine Datei vor. Den Link zum Herunterladen bekommst du per E-Mail.
correctEnglisch Correct my dataDeutsch Meine Daten berichtigen
correctTextEnglisch Tell us what is wrong, we fix it and confirm.Deutsch Sag uns, was nicht stimmt, wir korrigieren es und bestätigen.
correctLabelEnglisch What should we correct?Deutsch Was sollen wir berichtigen?
correctNeededEnglisch Please tell us what is wrong, so we know what to fix.Deutsch Bitte schreib, was nicht stimmt, damit wir wissen, was wir korrigieren.
correctDoneEnglisch Thank you, we correct it and confirm by email.Deutsch Danke, wir korrigieren es und bestätigen per E-Mail.
objectEnglisch Stop using my data for marketingDeutsch Meine Daten nicht mehr für Werbung nutzen
objectTextEnglisch No more offers or personal suggestions, your account stays.Deutsch Keine Angebote und persönlichen Vorschläge mehr, dein Konto bleibt.
objectDoneEnglisch Done. We no longer use your data for marketing.Deutsch Erledigt. Wir nutzen deine Daten nicht mehr für Werbung.
delEnglisch Delete my accountDeutsch Mein Konto löschen
delTextEnglisch Your account and data go for good. Invoices stay as long as the law asks.Deutsch Konto und Daten verschwinden endgültig. Rechnungen bleiben, solange das Gesetz es verlangt.
delConfirmEnglisch Type {w} to confirmDeutsch Tippe {w} zur Bestätigung
delWordEnglisch DELETEDeutsch LÖSCHEN
delNeededEnglisch Please type {w} in capitals, so nothing is deleted by accident.Deutsch Bitte tippe {w} in Großbuchstaben, damit nichts aus Versehen gelöscht wird.
delDoneEnglisch Your account will be deleted in 14 days. Until then you can change your mind in the email we sent.Deutsch Dein Konto wird in 14 Tagen gelöscht. Bis dahin kannst du es dir über die E-Mail noch anders überlegen.
requestEnglisch Send requestDeutsch Anfrage senden
answerEnglisch We answer {t}Deutsch Wir antworten {t}
sentEnglisch Request sentDeutsch Anfrage gesendet

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Einstellungen und Einwilligungen reagiert selbst auf die Tasten Entf.
  • Setzt aria-controls, aria-expanded, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 628 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Einstellungen und Einwilligungen?

Mit aiw add @ai-website/consent-preferences. Der Befehl legt die Datei components/ui/consent-preferences.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input, textarea und switch. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Einstellungen und Einwilligungen?

4 Varianten, E-Mail-Themen, Abmelden, Kanäle und Datenschutz-Anfragen. Du wählst sie mit der Prop variant, zum Beispiel variant="unsubscribe".

Kann ich die Texte von Einstellungen und Einwilligungen übersetzen?

Ja. Einstellungen und Einwilligungen bringt 49 eigene Texte auf Deutsch und Englisch mit, etwa „Wie oft?“, „Alle E-Mails 30 Tage pausieren“ und „Wir behalten deine Auswahl und starten nach der Pause wieder.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Einstellungen und Einwilligungen einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Einstellungen und Einwilligungen mit aiw_components und installieren sie mit aiw_add @ai-website/consent-preferences. Nenn im Prompt die Variante, etwa variant="email-topics", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Einstellungen und Einwilligungen ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ConsentPreferences-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert