Skip to content
UI Kit
Accent color

Payment form

Choosing how to pay and paying, without card brand logos. Methods that open their own fields, the order beside the payment with a promo code, a direct debit with a real IBAN check, and paying in parts with every date shown.

One of 3 components in Address, payment and consent

4 variants

Variants

Payment methods

Card, direct debit, invoice and bank transfer as cards. The chosen one opens its own fields with a soft height, the button says what happens, with the amount for a card.

Open in Studio

With order summary

The card beside the order. A promo code turns into a chip with the saving, the total rises in anew. On phones and tablets the order folds into a bar with the total above the payment.

Open in Studio

Direct debit

An IBAN that groups itself in fours, names the bank country and fills a thin bar to the expected length. It is checked with the real checksum and draws a tick when valid, the mandate stands in plain words.

Open in Studio

Pay in parts

Pay at once or in three or six parts at no extra cost. The plan beside the choice redraws with every date and amount, the button names what is charged today.

Open in Studio

Details

What Payment form brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/payment-form.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputcheckboxcard-input
npm packages
None
Languages
English and German, 58 texts of its own

Props

The PaymentFormProps interface from components/ui/payment-form.tsx, with the comments from the code.

  • variantoptional
    PaymentFormVariant

    methodssplit-summarydirect-debitinstallments

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • totalrequired
    number

    Amount to pay

  • currencyoptional
    string

    ISO currency, e.g. EUR or USD

  • methodsoptional
    ('card' | 'debit' | 'invoice' | 'transfer')[]

    Methods to offer in methods

  • linesoptional
    PaymentLine[]

    split-summary: the order lines and shipping

  • shippingoptional
    number
  • promoCodesoptional
    Record<string, number>

    split-summary: codes that work and their discount in percent

  • creditoroptional
    string

    direct-debit: who collects, for the mandate text

  • plansoptional
    number[]

    installments: plans in number of parts, e.g. [1, 3, 6], and the first date

  • startDateoptional
    string
  • submitLabeloptional
    string
  • onSubmitoptional
    (data: Record<string, unknown>) => void

Built-in texts

Payment form writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 58 texts
methodEnglish How would you like to pay?German Wie möchtest du bezahlen?
cardEnglish CardGerman Karte
cardTextEnglish Credit or debit card, charged when you orderGerman Kredit- oder Debitkarte, belastet bei der Bestellung
debitEnglish Direct debitGerman Lastschrift
debitTextEnglish From your bank account, about two days after orderingGerman Vom Bankkonto, etwa zwei Tage nach der Bestellung
invoiceEnglish InvoiceGerman Rechnung
invoiceTextEnglish Pay within 14 days, for businessesGerman Zahlung innerhalb von 14 Tagen, für Firmen
transferEnglish Bank transferGerman Überweisung
transferTextEnglish We send the details, the order ships when the money arrivesGerman Wir schicken die Daten, die Bestellung geht raus, sobald das Geld da ist
cardNeededEnglish Please complete the card details, so the payment goes through.German Bitte vervollständige die Kartendaten, damit die Zahlung durchgeht.
holderEnglish Account holderGerman Kontoinhaber
holderNeededEnglish Please add the account holder, it must match the bank account.German Bitte gib den Kontoinhaber an, er muss zum Konto passen.
ibanEnglish IBANGerman IBAN
ibanHintEnglish Starts with two letters, for example DEGerman Beginnt mit zwei Buchstaben, zum Beispiel DE
ibanNeededEnglish This IBAN does not add up. Please check it against your bank card or app, one digit is often enough.German Diese IBAN geht nicht auf. Bitte prüf sie mit deiner Bankkarte oder App, oft ist es nur eine Ziffer.
mandateEnglish I authorise {c} to collect payments from my account by direct debit and instruct my bank to pay them. I can ask for a refund within eight weeks of the debit.German Ich ermächtige {c}, Zahlungen von meinem Konto per Lastschrift einzuziehen, und weise meine Bank an, sie einzulösen. Ich kann innerhalb von acht Wochen nach der Belastung die Erstattung verlangen.
mandateNeededEnglish Please agree to the mandate, without it we cannot collect the amount.German Bitte stimme dem Mandat zu, ohne es können wir den Betrag nicht einziehen.
companyEnglish CompanyGerman Firma
mandateAgreeEnglish I agree to the direct debit mandateGerman Ich erteile das Lastschriftmandat
setupEnglish Set up direct debitGerman Lastschrift einrichten
secureDebitEnglish Encrypted. Your account details are used only for this mandate.German Verschlüsselt. Deine Kontodaten nutzen wir nur für dieses Mandat.
mandateShortEnglish Direct debit mandateGerman Lastschriftmandat
companyNeededEnglish Please add the company, the invoice is made out to it.German Bitte gib die Firma an, die Rechnung wird auf sie ausgestellt.
vatEnglish VAT numberGerman USt-IdNr.
transferNoteEnglish You get our bank details and a reference by email. The order is reserved for seven days.German Bankdaten und Verwendungszweck kommen per E-Mail. Die Bestellung bleibt sieben Tage reserviert.
payEnglish Pay {a}German {a} bezahlen
orderEnglish Place orderGerman Bestellung abschicken
secureEnglish Encrypted. We never see your full card number.German Verschlüsselt. Deine volle Kartennummer sehen wir nie.
summaryEnglish Your orderGerman Deine Bestellung
showSummaryEnglish Show orderGerman Bestellung zeigen
hideSummaryEnglish Hide orderGerman Bestellung ausblenden
subtotalEnglish SubtotalGerman Zwischensumme
shippingEnglish ShippingGerman Versand
freeEnglish FreeGerman Kostenlos
discountEnglish Discount {c}German Rabatt {c}
totalEnglish TotalGerman Gesamt
promoEnglish Promo codeGerman Gutscheincode
applyEnglish ApplyGerman Einlösen
promoBadEnglish This code does not work. Check the spelling, codes do not care about upper or lower case.German Dieser Code funktioniert nicht. Prüf die Schreibweise, Groß- und Kleinschreibung ist egal.
promoOkEnglish {c} applied, you save {a}German {c} eingelöst, du sparst {a}
removeEnglish Remove codeGerman Code entfernen
countryEnglish Bank in {c}German Bank in {c}
planTitleEnglish How would you like to pay?German Wie möchtest du zahlen?
nowEnglish All at onceGerman Auf einmal
partsEnglish In {n} partsGerman In {n} Raten
noFeeEnglish No extra costGerman Ohne Aufpreis
nowTextEnglish One payment todayGerman Eine Zahlung heute
monthlyEnglish a monthGerman im Monat
scheduleEnglish Your planGerman Dein Zahlplan
todayEnglish TodayGerman Heute
paidTitleEnglish Payment receivedGerman Zahlung erhalten
paidTextEnglish A receipt is on its way to your inbox.German Die Quittung ist unterwegs in dein Postfach.
debitTitleEnglish Mandate set upGerman Mandat eingerichtet
debitDoneEnglish We collect {a} in about two days. You get a notice before every debit.German Wir ziehen {a} in etwa zwei Tagen ein. Vor jeder Abbuchung bekommst du eine Nachricht.
invoiceTitleEnglish Order placed on invoiceGerman Auf Rechnung bestellt
invoiceDoneTextEnglish The invoice comes with the delivery. Please pay within 14 days.German Die Rechnung kommt mit der Lieferung. Bitte zahl innerhalb von 14 Tagen.
transferTitleEnglish Order reservedGerman Bestellung reserviert
transferDoneTextEnglish The bank details are on their way by email.German Die Bankdaten sind per E-Mail unterwegs.

Accessibility

Read from the source of the component.

  • Payment form handles the keys Enter itself.
  • Sets aria-expanded and aria-label, 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.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 759 lines
components/ui/payment-form.tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn, round, 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 { Checkbox } from '@/components/ui/checkbox';
import { CardInput, type CardValue } from '@/components/ui/card-input';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DoneMark, ErrorSummary, FlowHead, Unfold, summaryOf } from '@/components/ui/form-flow';

/*
 * Choosing how to pay and paying, without card brand logos: the kit names
 * methods by what they are. Card fields come from card-input, the account
 * number is checked with the real IBAN checksum. Nothing here talks to a
 * payment provider, `onSubmit` hands the choice to yours.
 *
 *   methods        card, direct debit, invoice or bank transfer, each opens its own fields
 *   split-summary  the order beside the payment, a promo code, the summary folds on phones
 *   direct-debit   an IBAN that groups itself as you type, checked, with the mandate in plain words
 *   installments   pay now or in parts, the plan shows every date and amount
 */

export type PaymentFormVariant = 'methods' | 'split-summary' | 'direct-debit' | 'installments';

export interface PaymentLine {
  title: string;
  detail?: string;
  /** Whole currency units, decimals allowed */
  amount: number;
  quantity?: number;
}

export interface PaymentFormProps {
  variant?: PaymentFormVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Amount to pay */
  total: number;
  /** ISO currency, e.g. EUR or USD */
  currency?: string;
  /** Methods to offer in methods */
  methods?: ('card' | 'debit' | 'invoice' | 'transfer')[];
  /** split-summary: the order lines and shipping */
  lines?: PaymentLine[];
  shipping?: number;
  /** split-summary: codes that work and their discount in percent */
  promoCodes?: Record<string, number>;
  /** direct-debit: who collects, for the mandate text */
  creditor?: string;
  /** installments: plans in number of parts, e.g. [1, 3, 6], and the first date */
  plans?: number[];
  startDate?: string;
  submitLabel?: string;
  onSubmit?: (data: Record<string, unknown>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    method: 'How would you like to pay?',
    card: 'Card',
    cardText: 'Credit or debit card, charged when you order',
    debit: 'Direct debit',
    debitText: 'From your bank account, about two days after ordering',
    invoice: 'Invoice',
    invoiceText: 'Pay within 14 days, for businesses',
    transfer: 'Bank transfer',
    transferText: 'We send the details, the order ships when the money arrives',
    cardNeeded: 'Please complete the card details, so the payment goes through.',
    holder: 'Account holder',
    holderNeeded: 'Please add the account holder, it must match the bank account.',
    iban: 'IBAN',
    ibanHint: 'Starts with two letters, for example DE',
    ibanNeeded: 'This IBAN does not add up. Please check it against your bank card or app, one digit is often enough.',
    mandate: 'I authorise {c} to collect payments from my account by direct debit and instruct my bank to pay them. I can ask for a refund within eight weeks of the debit.',
    mandateNeeded: 'Please agree to the mandate, without it we cannot collect the amount.',
    company: 'Company',
    mandateAgree: 'I agree to the direct debit mandate',
    setup: 'Set up direct debit',
    secureDebit: 'Encrypted. Your account details are used only for this mandate.',
    mandateShort: 'Direct debit mandate',
    companyNeeded: 'Please add the company, the invoice is made out to it.',
    vat: 'VAT number',
    transferNote: 'You get our bank details and a reference by email. The order is reserved for seven days.',
    pay: 'Pay {a}',
    order: 'Place order',
    secure: 'Encrypted. We never see your full card number.',
    summary: 'Your order',
    showSummary: 'Show order',
    hideSummary: 'Hide order',
    subtotal: 'Subtotal',
    shipping: 'Shipping',
    free: 'Free',
    discount: 'Discount {c}',
    total: 'Total',
    promo: 'Promo code',
    apply: 'Apply',
    promoBad: 'This code does not work. Check the spelling, codes do not care about upper or lower case.',
    promoOk: '{c} applied, you save {a}',
    remove: 'Remove code',
    country: 'Bank in {c}',
    planTitle: 'How would you like to pay?',
    now: 'All at once',
    parts: 'In {n} parts',
    noFee: 'No extra cost',
    nowText: 'One payment today',
    monthly: 'a month',
    schedule: 'Your plan',
    today: 'Today',
    paidTitle: 'Payment received',
    paidText: 'A receipt is on its way to your inbox.',
    debitTitle: 'Mandate set up',
    debitDone: 'We collect {a} in about two days. You get a notice before every debit.',
    invoiceTitle: 'Order placed on invoice',
    invoiceDoneText: 'The invoice comes with the delivery. Please pay within 14 days.',
    transferTitle: 'Order reserved',
    transferDoneText: 'The bank details are on their way by email.',
  },
  de: {
    method: 'Wie möchtest du bezahlen?',
    card: 'Karte',
    cardText: 'Kredit- oder Debitkarte, belastet bei der Bestellung',
    debit: 'Lastschrift',
    debitText: 'Vom Bankkonto, etwa zwei Tage nach der Bestellung',
    invoice: 'Rechnung',
    invoiceText: 'Zahlung innerhalb von 14 Tagen, für Firmen',
    transfer: 'Überweisung',
    transferText: 'Wir schicken die Daten, die Bestellung geht raus, sobald das Geld da ist',
    cardNeeded: 'Bitte vervollständige die Kartendaten, damit die Zahlung durchgeht.',
    holder: 'Kontoinhaber',
    holderNeeded: 'Bitte gib den Kontoinhaber an, er muss zum Konto passen.',
    iban: 'IBAN',
    ibanHint: 'Beginnt mit zwei Buchstaben, zum Beispiel DE',
    ibanNeeded: 'Diese IBAN geht nicht auf. Bitte prüf sie mit deiner Bankkarte oder App, oft ist es nur eine Ziffer.',
    mandate: 'Ich ermächtige {c}, Zahlungen von meinem Konto per Lastschrift einzuziehen, und weise meine Bank an, sie einzulösen. Ich kann innerhalb von acht Wochen nach der Belastung die Erstattung verlangen.',
    mandateNeeded: 'Bitte stimme dem Mandat zu, ohne es können wir den Betrag nicht einziehen.',
    company: 'Firma',
    mandateAgree: 'Ich erteile das Lastschriftmandat',
    setup: 'Lastschrift einrichten',
    secureDebit: 'Verschlüsselt. Deine Kontodaten nutzen wir nur für dieses Mandat.',
    mandateShort: 'Lastschriftmandat',
    companyNeeded: 'Bitte gib die Firma an, die Rechnung wird auf sie ausgestellt.',
    vat: 'USt-IdNr.',
    transferNote: 'Bankdaten und Verwendungszweck kommen per E-Mail. Die Bestellung bleibt sieben Tage reserviert.',
    pay: '{a} bezahlen',
    order: 'Bestellung abschicken',
    secure: 'Verschlüsselt. Deine volle Kartennummer sehen wir nie.',
    summary: 'Deine Bestellung',
    showSummary: 'Bestellung zeigen',
    hideSummary: 'Bestellung ausblenden',
    subtotal: 'Zwischensumme',
    shipping: 'Versand',
    free: 'Kostenlos',
    discount: 'Rabatt {c}',
    total: 'Gesamt',
    promo: 'Gutscheincode',
    apply: 'Einlösen',
    promoBad: 'Dieser Code funktioniert nicht. Prüf die Schreibweise, Groß- und Kleinschreibung ist egal.',
    promoOk: '{c} eingelöst, du sparst {a}',
    remove: 'Code entfernen',
    country: 'Bank in {c}',
    planTitle: 'Wie möchtest du zahlen?',
    now: 'Auf einmal',
    parts: 'In {n} Raten',
    noFee: 'Ohne Aufpreis',
    nowText: 'Eine Zahlung heute',
    monthly: 'im Monat',
    schedule: 'Dein Zahlplan',
    today: 'Heute',
    paidTitle: 'Zahlung erhalten',
    paidText: 'Die Quittung ist unterwegs in dein Postfach.',
    debitTitle: 'Mandat eingerichtet',
    debitDone: 'Wir ziehen {a} in etwa zwei Tagen ein. Vor jeder Abbuchung bekommst du eine Nachricht.',
    invoiceTitle: 'Auf Rechnung bestellt',
    invoiceDoneText: 'Die Rechnung kommt mit der Lieferung. Bitte zahl innerhalb von 14 Tagen.',
    transferTitle: 'Bestellung reserviert',
    transferDoneText: 'Die Bankdaten sind per E-Mail unterwegs.',
  },
};

type Texts = (typeof TEXTS)['en'];

function useMoney(currency = 'EUR') {
  const locale = useLocale();
  const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency });
  return (n: number) => nf.format(round(n, 2));
}

/* ------------------------------------------------------------------ */
/* IBAN                                                                */
/* ------------------------------------------------------------------ */

const IBAN_LENGTH: Record<string, number> = { DE: 22, AT: 20, CH: 21, NL: 18, FR: 27, BE: 16, IT: 27, ES: 24, LU: 20, GB: 22 };
const IBAN_COUNTRY: Record<string, { en: string; de: string }> = {
  DE: { en: 'Germany', de: 'Deutschland' },
  AT: { en: 'Austria', de: 'Österreich' },
  CH: { en: 'Switzerland', de: 'Schweiz' },
  NL: { en: 'Netherlands', de: 'Niederlande' },
  FR: { en: 'France', de: 'Frankreich' },
  BE: { en: 'Belgium', de: 'Belgien' },
  IT: { en: 'Italy', de: 'Italien' },
  ES: { en: 'Spain', de: 'Spanien' },
  LU: { en: 'Luxembourg', de: 'Luxemburg' },
  GB: { en: 'United Kingdom', de: 'Vereinigtes Königreich' },
};

const ibanClean = (v: string) => v.replace(/\s+/g, '').toUpperCase();
const ibanGroup = (v: string) => ibanClean(v).replace(/(.{4})/g, '$1 ').trim();

/** The ISO 13616 check: move the first four characters to the end, letters to numbers, mod 97 must be 1 */
export function ibanValid(v: string) {
  const s = ibanClean(v);
  if (!/^[A-Z]{2}\d{2}[A-Z0-9]{8,30}$/.test(s)) return false;
  const len = IBAN_LENGTH[s.slice(0, 2)];
  if (len && s.length !== len) return false;
  const moved = s.slice(4) + s.slice(0, 4);
  let rest = 0;
  for (const ch of moved) {
    const n = ch >= 'A' ? String(ch.charCodeAt(0) - 55) : ch;
    for (const d of n) rest = (rest * 10 + Number(d)) % 97;
  }
  return rest === 1;
}

function IbanField({ id, t, value, set, error }: { id: string; t: Texts; value: string; set: (v: string) => void; error?: string }) {
  const locale = useLocale();
  const clean = ibanClean(value);
  const country = IBAN_COUNTRY[clean.slice(0, 2)];
  const len = IBAN_LENGTH[clean.slice(0, 2)];
  const ok = ibanValid(value);
  return (
    <div>
      <Input
        id={id}
        label={t.iban}
        required
        autoComplete="off"
        spellCheck={false}
        inputMode="text"
        placeholder="DE89 3704 0044 0532 0130 00"
        hint={error ? undefined : country ? t.country.replace('{c}', country[locale]) : t.ibanHint}
        value={value}
        onChange={(e) => set(ibanGroup(e.target.value).slice(0, 42))}
        success={ok}
        error={error}
        size="lg"
        inputClassName="tabular-nums max-sm:text-[15px] sm:tracking-[0.04em]"
      />
      {/* A thin bar fills to the length this country expects */}
      {len && !ok && (
        <div aria-hidden className="mt-2 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]">
          <div className="h-full origin-left rounded-full bg-primary transition-transform duration-300" style={{ transform: `scaleX(${round(Math.min(1, clean.length / len), 3)})` }} />
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Shared                                                              */
/* ------------------------------------------------------------------ */

function Paid({ title, text }: { title: string; text: string }) {
  return (
    <div role="status" className="kit-rise flex flex-col items-center py-6 text-center">
      <DoneMark size={60} />
      <p className="mt-5 text-[21px] font-semibold tracking-[-0.02em] text-foreground">{title}</p>
      <p className="mt-2 max-w-[26rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{text}</p>
    </div>
  );
}

function Secure({ t, debit }: { t: Texts; debit?: boolean }) {
  return (
    <p className="text-balance text-center text-[12.5px] leading-snug text-muted-foreground">
      <Icon name="lock" size={13} className="-mt-0.5 me-1.5 inline-block align-middle" />
      {debit ? t.secureDebit : t.secure}
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* methods                                                             */
/* ------------------------------------------------------------------ */

type Method = 'card' | 'debit' | 'invoice' | 'transfer';
const METHOD_ICON: Record<Method, IconName> = { card: 'credit-card', debit: 'landmark', invoice: 'receipt', transfer: 'banknote' };

function Methods({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const money = useMoney(p.currency);
  const methods = p.methods ?? ['card', 'debit', 'invoice', 'transfer'];
  const [method, setMethod] = useState<Method>(methods[0]);
  const [card, setCard] = useState<CardValue | null>(null);
  const [holder, setHolder] = useState('');
  const [iban, setIban] = useState('');
  const [mandate, setMandate] = useState(false);
  const [company, setCompany] = useState('');
  const [vat, setVat] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [done, setDone] = useState(false);
  const summaryRef = useRef<HTMLDivElement>(null);
  const clear = (k: string) => errors[k] && setErrors((e) => Object.fromEntries(Object.entries(e).filter(([x]) => x !== k)));
  const labels: Record<Method, [string, string]> = { card: [t.card, t.cardText], debit: [t.debit, t.debitText], invoice: [t.invoice, t.invoiceText], transfer: [t.transfer, t.transferText] };
  const summary = summaryOf(errors, [
    ['card', `${id}-card`, t.card],
    ['holder', `${id}-holder`, t.holder],
    ['iban', `${id}-iban`, t.iban],
    ['mandate', `${id}-mandate`, t.mandateShort],
    ['company', `${id}-company`, t.company],
  ]);

  if (done) {
    const [title, text] = method === 'card' ? [t.paidTitle, t.paidText] : method === 'debit' ? [t.debitTitle, t.debitDone.replace('{a}', money(p.total))] : method === 'invoice' ? [t.invoiceTitle, t.invoiceDoneText] : [t.transferTitle, t.transferDoneText];
    return (
      <div className="kit-card mx-auto max-w-[40rem] p-6 sm:p-10">
        <Paid title={title} text={text} />
      </div>
    );
  }
  return (
    <Reveal effect="rise" className="mx-auto max-w-[40rem]">
      <form
        noValidate
        className="kit-card grid gap-5 p-6 sm:p-8"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (method === 'card' && !card?.complete) found.card = t.cardNeeded;
          if (method === 'debit') {
            if (!holder.trim()) found.holder = t.holderNeeded;
            if (!ibanValid(iban)) found.iban = t.ibanNeeded;
            if (!mandate) found.mandate = t.mandateNeeded;
          }
          if (method === 'invoice' && !company.trim()) found.company = t.companyNeeded;
          setErrors(found);
          if (Object.keys(found).length) {
            requestAnimationFrame(() => summaryRef.current?.focus());
            return;
          }
          p.onSubmit?.({ method, card, holder, iban: ibanClean(iban), company, vat });
          setDone(true);
        }}
      >
        <ErrorSummary ref={summaryRef} errors={summary} />
        <fieldset className="min-w-0">
          <legend className="mb-4 text-[15px] font-semibold text-foreground">{t.method}</legend>
          {/* Each method is a card that opens its own fields with a soft height */}
          <Stagger step={70} as="div" className="grid gap-2.5">
            {methods.map((m) => {
              const on = method === m;
              return (
                <div key={m} className={cn('overflow-hidden rounded-xl border transition-[border-color,box-shadow,background-color] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-card shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_45%,var(--border))]' : 'border-border bg-card hover:border-border-strong')}>
                  <label className="flex cursor-pointer items-center gap-3.5 p-4 has-[:focus-visible]:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
                    <input type="radio" name={`${id}-m`} checked={on} onChange={() => (setMethod(m), setErrors({}))} className="sr-only" />
                    <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
                      <span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
                    </span>
                    <span className="min-w-0 flex-1">
                      <span className="block text-[15px] font-medium text-foreground">{labels[m][0]}</span>
                      <span className="block text-[13px] leading-snug text-muted-foreground">{labels[m][1]}</span>
                    </span>
                    <Icon name={METHOD_ICON[m]} size={20} className={cn('shrink-0 transition-colors', on ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground')} />
                  </label>
                  <Unfold open={on}>
                    <div className="border-t border-border bg-surface p-4 sm:p-5">
                      {m === 'card' && (
                        <div id={`${id}-card`} tabIndex={-1} className="outline-none">
                          <CardInput variant="stacked" withName onValueChange={(v) => (setCard(v), clear('card'))} error={errors.card} />
                        </div>
                      )}
                      {m === 'debit' && (
                        <div className="grid gap-4">
                          <Input id={`${id}-holder`} label={t.holder} required autoComplete="name" value={holder} onChange={(e) => (setHolder(e.target.value), clear('holder'))} error={errors.holder} />
                          <IbanField id={`${id}-iban`} t={t} value={iban} set={(v) => (setIban(v), clear('iban'))} error={errors.iban} />
                          <Checkbox id={`${id}-mandate`} label={t.mandateAgree} description={t.mandate.replace('{c}', p.creditor ?? '')} checked={mandate} onChange={(e) => (setMandate(e.target.checked), clear('mandate'))} error={errors.mandate} />
                        </div>
                      )}
                      {m === 'invoice' && (
                        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                          <Input id={`${id}-company`} label={t.company} required autoComplete="organization" value={company} onChange={(e) => (setCompany(e.target.value), clear('company'))} error={errors.company} />
                          <Input label={t.vat} optional value={vat} onChange={(e) => setVat(e.target.value)} />
                        </div>
                      )}
                      {m === 'transfer' && <p className="text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.transferNote}</p>}
                    </div>
                  </Unfold>
                </div>
              );
            })}
          </Stagger>
        </fieldset>
        <Button type="submit" size="lg" icon={method === 'card' ? 'lock' : 'arrow-right'} className="w-full">
          {p.submitLabel ?? (method === 'card' ? t.pay.replace('{a}', money(p.total)) : t.order)}
        </Button>
        <Secure t={t} debit={method !== 'card'} />
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* split-summary                                                       */
/* ------------------------------------------------------------------ */

function SplitSummary({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const money = useMoney(p.currency);
  const lines = p.lines ?? [];
  const [card, setCard] = useState<CardValue | null>(null);
  const [error, setError] = useState('');
  const [code, setCode] = useState('');
  const [applied, setApplied] = useState<string | null>(null);
  const [codeError, setCodeError] = useState('');
  const [open, setOpen] = useState(false);
  const [done, setDone] = useState(false);
  const subtotal = lines.reduce((s, l) => s + l.amount * (l.quantity ?? 1), 0);
  const percent = applied ? (p.promoCodes?.[applied] ?? 0) : 0;
  const discount = round((subtotal * percent) / 100, 2);
  const total = subtotal - discount + (p.shipping ?? 0);
  const tryCode = () => {
    const key = Object.keys(p.promoCodes ?? {}).find((k) => k.toLowerCase() === code.trim().toLowerCase());
    if (!key) {
      setCodeError(t.promoBad);
      return;
    }
    setApplied(key);
    setCodeError('');
    setCode('');
  };
  const summary = (
    <div>
      <ul className="grid gap-3">
        {lines.map((l) => (
          <li key={l.title} className="flex items-start gap-3">
            <span className="relative grid size-12 shrink-0 place-items-center rounded-lg border border-border bg-surface-2 text-muted-foreground">
              <Icon name="package" size={18} />
              {(l.quantity ?? 1) > 1 && <span className="absolute -end-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-foreground text-[11px] font-semibold text-background">{l.quantity}</span>}
            </span>
            <span className="min-w-0 flex-1">
              <span className="block text-[14px] font-medium text-foreground">{l.title}</span>
              {l.detail && <span className="block text-[12.5px] text-muted-foreground">{l.detail}</span>}
            </span>
            <span className="text-[14px] tabular-nums text-foreground">{money(l.amount * (l.quantity ?? 1))}</span>
          </li>
        ))}
      </ul>
      {/* Promo code: applied codes become a chip with a way to remove */}
      <div className="mt-5 border-t border-border pt-5">
        {applied ? (
          <p className="kit-rise flex items-center justify-between gap-3 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] px-3 py-2 text-[13.5px] text-success-foreground">
            <span className="flex items-center gap-2">
              <Icon name="tag" size={15} />
              {t.promoOk.replace('{c}', applied).replace('{a}', money(discount))}
            </span>
            <button type="button" aria-label={t.remove} onClick={() => setApplied(null)} className="grid size-9 max-sm:size-11 place-items-center rounded-md text-muted-foreground hover:text-foreground focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
              <Icon name="x" size={15} />
            </button>
          </p>
        ) : (
          <div className="flex items-start gap-2">
            <Input
              aria-label={t.promo}
              placeholder={t.promo}
              value={code}
              onChange={(e) => (setCode(e.target.value), setCodeError(''))}
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  e.preventDefault();
                  tryCode();
                }
              }}
              error={codeError || undefined}
              className="min-w-0 flex-1"
            />
            <Button type="button" variant="outline" onClick={tryCode}>
              {t.apply}
            </Button>
          </div>
        )}
      </div>
      <dl className="mt-5 grid gap-2 border-t border-border pt-5 text-[14px]">
        <div className="flex justify-between">
          <dt className="text-muted-foreground">{t.subtotal}</dt>
          <dd className="tabular-nums text-foreground">{money(subtotal)}</dd>
        </div>
        {discount > 0 && (
          <div className="kit-rise flex justify-between">
            <dt className="text-muted-foreground">{t.discount.replace('{c}', applied ?? '')}</dt>
            <dd className="tabular-nums text-success-foreground">−{money(discount).replace('-', '')}</dd>
          </div>
        )}
        <div className="flex justify-between">
          <dt className="text-muted-foreground">{t.shipping}</dt>
          <dd className="tabular-nums text-foreground">{p.shipping ? money(p.shipping) : t.free}</dd>
        </div>
        <div className="mt-2 flex items-baseline justify-between border-t border-border pt-3">
          <dt className="text-[15px] font-semibold text-foreground">{t.total}</dt>
          <dd key={total} className="text-[20px] font-semibold tabular-nums tracking-[-0.02em] text-foreground motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">
            {money(total)}
          </dd>
        </div>
      </dl>
    </div>
  );
  if (done)
    return (
      <div className="kit-card mx-auto max-w-[40rem] p-6 sm:p-10">
        <Paid title={t.paidTitle} text={t.paidText} />
      </div>
    );
  return (
    <div className="mx-auto grid max-w-[62rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start lg:gap-10">
      {/* Phones and tablets: the order folds into a bar with the total */}
      <div className="kit-card overflow-hidden lg:hidden">
        <button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)} className="flex min-h-14 w-full items-center gap-3 px-5 text-start focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
          <Icon name="bag" size={18} className="text-muted-foreground" />
          <span className="flex-1 text-[14.5px] font-medium text-foreground">{open ? t.hideSummary : t.showSummary}</span>
          <span className="text-[15px] font-semibold tabular-nums text-foreground">{money(total)}</span>
          <Icon name="chevron-down" size={17} className={cn('text-muted-foreground transition-transform duration-300', open && 'rotate-180')} />
        </button>
        <Unfold open={open}>
          <div className="border-t border-border p-5">{summary}</div>
        </Unfold>
      </div>
      <Reveal effect="rise" className="min-w-0 lg:order-none">
        <form
          noValidate
          className="kit-card grid gap-5 p-6 sm:p-8"
          onSubmit={(e) => {
            e.preventDefault();
            if (!card?.complete) {
              setError(t.cardNeeded);
              document.getElementById(`${id}-card`)?.querySelector<HTMLElement>('input')?.focus();
              return;
            }
            p.onSubmit?.({ card, code: applied, total });
            setDone(true);
          }}
        >
          <p className="flex items-center gap-2.5 text-[15px] font-semibold text-foreground">
            <Icon name="credit-card" size={18} className="text-muted-foreground" />
            {t.card}
          </p>
          <div id={`${id}-card`}>
            <CardInput variant="preview" withName onValueChange={(v) => (setCard(v), setError(''))} error={error || undefined} />
          </div>
          <Button type="submit" size="lg" icon="lock" magnetic className="w-full">
            {p.submitLabel ?? t.pay.replace('{a}', money(total))}
          </Button>
          <Secure t={t} />
        </form>
      </Reveal>
      <Reveal effect="rise" delay={120} className="hidden lg:sticky lg:top-24 lg:block">
        <aside aria-label={t.summary} className="kit-card p-6">
          <p className="mb-5 text-[15px] font-semibold text-foreground">{t.summary}</p>
          {summary}
        </aside>
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* direct-debit                                                        */
/* ------------------------------------------------------------------ */

function DirectDebit({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const money = useMoney(p.currency);
  const [holder, setHolder] = useState('');
  const [iban, setIban] = useState('');
  const [mandate, setMandate] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [done, setDone] = useState(false);
  const summaryRef = useRef<HTMLDivElement>(null);
  const clear = (k: string) => errors[k] && setErrors((e) => Object.fromEntries(Object.entries(e).filter(([x]) => x !== k)));
  const summary = summaryOf(errors, [
    ['holder', `${id}-holder`, t.holder],
    ['iban', `${id}-iban`, t.iban],
    ['mandate', `${id}-mandate`, t.mandateShort],
  ]);
  if (done)
    return (
      <div className="kit-card mx-auto max-w-[38rem] p-6 sm:p-10">
        <Paid title={t.debitTitle} text={t.debitDone.replace('{a}', money(p.total))} />
      </div>
    );
  return (
    <Reveal effect="rise" className="mx-auto max-w-[38rem]">
      <form
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!holder.trim()) found.holder = t.holderNeeded;
          if (!ibanValid(iban)) found.iban = t.ibanNeeded;
          if (!mandate) found.mandate = t.mandateNeeded;
          setErrors(found);
          if (Object.keys(found).length) {
            requestAnimationFrame(() => summaryRef.current?.focus());
            return;
          }
          p.onSubmit?.({ holder, iban: ibanClean(iban) });
          setDone(true);
        }}
      >
        <div className="flex items-center justify-between gap-4 border-b border-border bg-surface px-6 py-4 sm:px-8">
          <span className="flex items-center gap-2.5 text-[14px] text-muted-foreground">
            <Icon name="landmark" size={17} />
            {t.debit}
          </span>
          <span className="text-[17px] font-semibold tabular-nums text-foreground">{money(p.total)}</span>
        </div>
        <div className="grid gap-5 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <Input id={`${id}-holder`} label={t.holder} required autoComplete="name" value={holder} onChange={(e) => (setHolder(e.target.value), clear('holder'))} error={errors.holder} />
          <IbanField id={`${id}-iban`} t={t} value={iban} set={(v) => (setIban(v), clear('iban'))} error={errors.iban} />
          {/* The mandate in plain words, in a quiet box */}
          <div className={cn('rounded-xl border p-4 transition-colors', errors.mandate ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border bg-surface')}>
            <Checkbox id={`${id}-mandate`} label={t.mandateAgree} description={t.mandate.replace('{c}', p.creditor ?? '')} checked={mandate} onChange={(e) => (setMandate(e.target.checked), clear('mandate'))} error={errors.mandate} />
          </div>
          <Button type="submit" size="lg" icon="check" className="w-full">
            {p.submitLabel ?? t.setup}
          </Button>
          <Secure t={t} debit />
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* installments                                                        */
/* ------------------------------------------------------------------ */

function Installments({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const locale = useLocale();
  const money = useMoney(p.currency);
  const plans = p.plans ?? [1, 3, 6];
  const [plan, setPlan] = useState(plans[0]);
  const [card, setCard] = useState<CardValue | null>(null);
  const [error, setError] = useState('');
  const [done, setDone] = useState(false);
  const start = p.startDate ?? '2026-10-21';
  const part = round(p.total / plan, 2);
  const dates = Array.from({ length: plan }, (_, i) => {
    const [y, m, d] = start.split('-').map(Number);
    const date = new Date(Date.UTC(y, m - 1 + i, d));
    return i === 0 ? t.today : date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
  });
  if (done)
    return (
      <div className="kit-card mx-auto max-w-[40rem] p-6 sm:p-10">
        <Paid title={t.paidTitle} text={t.paidText} />
      </div>
    );
  return (
    <div className="mx-auto grid max-w-[60rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-8">
      <Reveal effect="rise" className="kit-card p-6 sm:p-8">
        <fieldset className="min-w-0">
          <legend className="mb-4 text-[15px] font-semibold text-foreground">{t.planTitle}</legend>
          <Stagger step={80} as="div" className="grid gap-2.5">
            {plans.map((n) => {
              const on = plan === n;
              return (
                <label key={n} className={cn('flex cursor-pointer items-center gap-4 rounded-xl border bg-card p-4 transition-[border-color,box-shadow] duration-300 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))] 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}-plan`} checked={on} onChange={() => setPlan(n)} className="sr-only" />
                  <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
                    <span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300', on ? 'scale-100' : 'scale-0')} />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block text-[15px] font-medium text-foreground">{n === 1 ? t.now : t.parts.replace('{n}', String(n))}</span>
                    <span className="block text-[13px] text-muted-foreground">{n === 1 ? t.nowText : t.noFee}</span>
                  </span>
                  <span className="shrink-0 text-end tabular-nums">
                    <span className="block text-[15px] font-semibold text-foreground">{money(n === 1 ? p.total : round(p.total / n, 2))}</span>
                    {n > 1 && <span className="block text-[12px] text-muted-foreground">{t.monthly}</span>}
                  </span>
                </label>
              );
            })}
          </Stagger>
        </fieldset>
        {/* The plan as a rail of dates, it redraws for every choice */}
        <div className="mt-7 border-t border-border pt-6">
          <p className="text-[13px] font-medium text-muted-foreground">{t.schedule}</p>
          <ol key={plan} className="relative mt-4 grid gap-0 ps-6">
            {plan > 1 && <span aria-hidden className="absolute bottom-3 start-[7px] top-3 w-px bg-border" />}
            {dates.map((d, i) => (
              <li key={d} className="kit-rise relative flex items-baseline justify-between gap-3 py-1.5 text-[14px]" style={{ '--kit-delay': `${i * 60}ms` } as React.CSSProperties}>
                <span aria-hidden className={cn('absolute -start-6 top-[11px] size-[15px] rounded-full border-2', i === 0 ? 'border-primary bg-primary' : 'border-border-strong bg-card')} />
                <span className={i === 0 ? 'font-medium text-foreground' : 'text-muted-foreground'}>{d}</span>
                <span className="tabular-nums text-foreground">{money(part)}</span>
              </li>
            ))}
          </ol>
        </div>
      </Reveal>
      <Reveal effect="rise" delay={100}>
        <form
          noValidate
          className="kit-card grid gap-5 p-6 sm:p-8"
          onSubmit={(e) => {
            e.preventDefault();
            if (!card?.complete) {
              setError(t.cardNeeded);
              const form = e.currentTarget;
              requestAnimationFrame(() => form.querySelector<HTMLElement>('input[autocomplete="cc-number"]')?.focus());
              return;
            }
            p.onSubmit?.({ plan, card });
            setDone(true);
          }}
        >
          <p className="flex items-center gap-2.5 text-[15px] font-semibold text-foreground">
            <Icon name="credit-card" size={18} className="text-muted-foreground" />
            {t.card}
          </p>
          <CardInput variant="stacked" withName onValueChange={(v) => (setCard(v), setError(''))} error={error || undefined} />
          <Button type="submit" size="lg" icon="lock" className="w-full">
            {p.submitLabel ?? t.pay.replace('{a}', money(part))}
          </Button>
          <Secure t={t} />
        </form>
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

/**
 * Choosing how to pay and paying, without card brand logos.
 *   <PaymentForm variant="methods" total={89} currency="EUR" creditor="Northwind GmbH" onSubmit={(data) => …} />
 */
export function PaymentForm(props: PaymentFormProps) {
  const { variant = 'methods', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section className={className}>
      <FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
      {variant === 'methods' && <Methods p={props} t={t} />}
      {variant === 'split-summary' && <SplitSummary p={props} t={t} />}
      {variant === 'direct-debit' && <DirectDebit p={props} t={t} />}
      {variant === 'installments' && <Installments p={props} t={t} />}
    </Section>
  );
}

Frequent questions

How do I install Payment form?

With aiw add @ai-website/payment-form. The command puts components/ui/payment-form.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input, checkbox and card-input. It needs no further npm packages.

Which variants does Payment form have?

4 variants, Payment methods, With order summary, Direct debit and Pay in parts. You pick one with the variant prop, for example variant="split-summary".

Can I translate the texts of Payment form?

Yes. Payment form carries 58 texts of its own in English and German, such as “How would you like to pay?”, “Card” and “Credit or debit card, charged when you order”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Payment form?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Payment form with aiw_components and install it with aiw_add @ai-website/payment-form. Name the variant in your prompt, for example variant="methods", and the assistant writes your content straight into the props.

Can I adjust Payment form without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching PaymentForm code to copy.

Often searched

Jump to

Copied to the clipboard