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
Variants
Details
What Payment form brings along, read from the registry and the source.
- Category
- Address, payment and consent
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/payment-form.tsx
- Brings along
- utilskitbuttoniconrevealsectionmotionform-flowinputcheckboxcard-input
- npm packages
- None
- Languages
- English and German, 58 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PaymentFormProps interface from components/ui/payment-form.tsx, with the comments from the code.
variantoptionalPaymentFormVariantmethodssplit-summarydirect-debitinstallments
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodetotalrequirednumberAmount to pay
currencyoptionalstringISO currency, e.g. EUR or USD
methodsoptional('card' | 'debit' | 'invoice' | 'transfer')[]Methods to offer in methods
linesoptionalPaymentLine[]split-summary: the order lines and shipping
shippingoptionalnumberpromoCodesoptionalRecord<string, number>split-summary: codes that work and their discount in percent
creditoroptionalstringdirect-debit: who collects, for the mandate text
plansoptionalnumber[]installments: plans in number of parts, e.g. [1, 3, 6], and the first date
startDateoptionalstringsubmitLabeloptionalstringonSubmitoptional(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
| Key | English | German |
|---|---|---|
| method | English How would you like to pay? | German Wie möchtest du bezahlen? |
| card | English Card | German Karte |
| cardText | English Credit or debit card, charged when you order | German Kredit- oder Debitkarte, belastet bei der Bestellung |
| debit | English Direct debit | German Lastschrift |
| debitText | English From your bank account, about two days after ordering | German Vom Bankkonto, etwa zwei Tage nach der Bestellung |
| invoice | English Invoice | German Rechnung |
| invoiceText | English Pay within 14 days, for businesses | German Zahlung innerhalb von 14 Tagen, für Firmen |
| transfer | English Bank transfer | German Überweisung |
| transferText | English We send the details, the order ships when the money arrives | German Wir schicken die Daten, die Bestellung geht raus, sobald das Geld da ist |
| cardNeeded | English Please complete the card details, so the payment goes through. | German Bitte vervollständige die Kartendaten, damit die Zahlung durchgeht. |
| holder | English Account holder | German Kontoinhaber |
| holderNeeded | English Please add the account holder, it must match the bank account. | German Bitte gib den Kontoinhaber an, er muss zum Konto passen. |
| iban | English IBAN | German IBAN |
| ibanHint | English Starts with two letters, for example DE | German Beginnt mit zwei Buchstaben, zum Beispiel DE |
| ibanNeeded | English 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. |
| mandate | English 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. |
| mandateNeeded | English 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. |
| company | English Company | German Firma |
| mandateAgree | English I agree to the direct debit mandate | German Ich erteile das Lastschriftmandat |
| setup | English Set up direct debit | German Lastschrift einrichten |
| secureDebit | English Encrypted. Your account details are used only for this mandate. | German Verschlüsselt. Deine Kontodaten nutzen wir nur für dieses Mandat. |
| mandateShort | English Direct debit mandate | German Lastschriftmandat |
| companyNeeded | English Please add the company, the invoice is made out to it. | German Bitte gib die Firma an, die Rechnung wird auf sie ausgestellt. |
| vat | English VAT number | German USt-IdNr. |
| transferNote | English 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. |
| pay | English Pay {a} | German {a} bezahlen |
| order | English Place order | German Bestellung abschicken |
| secure | English Encrypted. We never see your full card number. | German Verschlüsselt. Deine volle Kartennummer sehen wir nie. |
| summary | English Your order | German Deine Bestellung |
| showSummary | English Show order | German Bestellung zeigen |
| hideSummary | English Hide order | German Bestellung ausblenden |
| subtotal | English Subtotal | German Zwischensumme |
| shipping | English Shipping | German Versand |
| free | English Free | German Kostenlos |
| discount | English Discount {c} | German Rabatt {c} |
| total | English Total | German Gesamt |
| promo | English Promo code | German Gutscheincode |
| apply | English Apply | German Einlösen |
| promoBad | English 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. |
| promoOk | English {c} applied, you save {a} | German {c} eingelöst, du sparst {a} |
| remove | English Remove code | German Code entfernen |
| country | English Bank in {c} | German Bank in {c} |
| planTitle | English How would you like to pay? | German Wie möchtest du zahlen? |
| now | English All at once | German Auf einmal |
| parts | English In {n} parts | German In {n} Raten |
| noFee | English No extra cost | German Ohne Aufpreis |
| nowText | English One payment today | German Eine Zahlung heute |
| monthly | English a month | German im Monat |
| schedule | English Your plan | German Dein Zahlplan |
| today | English Today | German Heute |
| paidTitle | English Payment received | German Zahlung erhalten |
| paidText | English A receipt is on its way to your inbox. | German Die Quittung ist unterwegs in dein Postfach. |
| debitTitle | English Mandate set up | German Mandat eingerichtet |
| debitDone | English 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. |
| invoiceTitle | English Order placed on invoice | German Auf Rechnung bestellt |
| invoiceDoneText | English The invoice comes with the delivery. Please pay within 14 days. | German Die Rechnung kommt mit der Lieferung. Bitte zahl innerhalb von 14 Tagen. |
| transferTitle | English Order reserved | German Bestellung reserviert |
| transferDoneText | English 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
'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.