Payment Form

Card entry with brand detection, live formatting, Luhn validation and a preview that flips to the CVC.

  • Forms
  • Travel
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. 3D flip uses preserve-3d.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/payment-form.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/forms/payment-form.tsx
1/**
2 * PaymentForm — card entry with brand detection, live formatting, Luhn and
3 * expiry validation, a country select and a live card preview that flips to
4 * show the CVC while that field is focused.
5 *
6 * Props: onSubmit(values) (may return a Promise: button shows loading → paid),
7 * amountLabel (e.g. "€1,248.60"), countries[], defaultName, theme.
8 * Exports: PaymentForm, CardPreview, detectBrand, formatCardNumber, luhn.
9 * No card data leaves the component; wire `onSubmit` to your PSP's tokenizer —
10 * never post raw card numbers to your own server.
11 *
12 * Browser support: all evergreen browsers (3D flip uses preserve-3d; falls
13 * back to a fade where unsupported by prefers-reduced-motion).
14 * Accessibility: real labels, autocomplete tokens (cc-number, cc-exp, cc-csc,
15 * cc-name, country), inputmode="numeric", errors tied via aria-describedby and
16 * announced, the preview is aria-hidden (decorative mirror of the inputs).
17 */
18import { useId, useRef, useState } from "react";
19import type { ReactNode } from "react";
20
21export type CardBrand = "visa" | "mastercard" | "amex" | "discover" | "jcb" | "unionpay" | "unknown";
22
23export function detectBrand(digits: string): CardBrand {
24 if (/^4/.test(digits)) return "visa";
25 if (/^(5[1-5]|2(2[2-9][1-9]|2[3-9]|[3-6]|7[01]|720))/.test(digits)) return "mastercard";
26 if (/^3[47]/.test(digits)) return "amex";
27 if (/^(6011|65|64[4-9])/.test(digits)) return "discover";
28 if (/^35(2[89]|[3-8])/.test(digits)) return "jcb";
29 if (/^62/.test(digits)) return "unionpay";
30 return "unknown";
31}
32
33export function formatCardNumber(digits: string, brand: CardBrand = detectBrand(digits)) {
34 const d = digits.slice(0, brand === "amex" ? 15 : 19);
35 if (brand === "amex") return [d.slice(0, 4), d.slice(4, 10), d.slice(10, 15)].filter(Boolean).join(" ");
36 return (d.match(/.{1,4}/g) ?? []).join(" ");
37}
38
39export function luhn(digits: string) {
40 let sum = 0;
41 let alt = false;
42 for (let i = digits.length - 1; i >= 0; i--) {
43 let n = Number(digits[i]);
44 if (alt) {
45 n *= 2;
46 if (n > 9) n -= 9;
47 }
48 sum += n;
49 alt = !alt;
50 }
51 return digits.length >= 12 && sum % 10 === 0;
52}
53
54const formatExpiry = (digits: string) => {
55 let d = digits.slice(0, 4);
56 if (d.length === 1 && Number(d) > 1) d = "0" + d;
57 return d.length > 2 ? `${d.slice(0, 2)} / ${d.slice(2)}` : d.length === 2 ? `${d} / ` : d;
58};
59
60export interface PaymentValues {
61 number: string;
62 brand: CardBrand;
63 expiry: string;
64 cvc: string;
65 name: string;
66 country: string;
67}
68
69export interface PaymentFormProps {
70 onSubmit?: (v: PaymentValues) => void | Promise<unknown>;
71 amountLabel?: string;
72 countries?: { code: string; name: string }[];
73 defaultName?: string;
74 theme?: "light" | "dark";
75 className?: string;
76}
77
78const COUNTRIES = [
79 { code: "PT", name: "Portugal" },
80 { code: "GB", name: "United Kingdom" },
81 { code: "DE", name: "Germany" },
82 { code: "FR", name: "France" },
83 { code: "ES", name: "Spain" },
84 { code: "NL", name: "Netherlands" },
85 { code: "US", name: "United States" },
86 { code: "CA", name: "Canada" },
87 { code: "AU", name: "Australia" },
88 { code: "JP", name: "Japan" },
89];
90
91function useMask(fmt: (d: string) => string, set: (v: string) => void) {
92 return (e: React.ChangeEvent<HTMLInputElement>) => {
93 const el = e.target;
94 const raw = el.value;
95 const pos = el.selectionStart ?? raw.length;
96 const before = raw.slice(0, pos).replace(/\D/g, "").length;
97 const out = fmt(raw.replace(/\D/g, ""));
98 set(out);
99 let idx = out.length;
100 if (before === 0) idx = 0;
101 else {
102 let n = 0;
103 for (let i = 0; i < out.length; i++) {
104 if (/\d/.test(out[i])) n++;
105 if (n === before) {
106 idx = i + 1;
107 break;
108 }
109 }
110 }
111 requestAnimationFrame(() => {
112 try {
113 el.setSelectionRange(idx, idx);
114 } catch {
115 /* element may have unmounted */
116 }
117 });
118 };
119}
120
121export function PaymentForm({ onSubmit, amountLabel = "$0.00", countries = COUNTRIES, defaultName = "", theme, className }: PaymentFormProps) {
122 const uid = useId();
123 const [number, setNumber] = useState("");
124 const [expiry, setExpiry] = useState("");
125 const [cvc, setCvc] = useState("");
126 const [name, setName] = useState(defaultName);
127 const [country, setCountry] = useState(countries[0]?.code ?? "");
128 const [touched, setTouched] = useState<Record<string, boolean>>({});
129 const [flipped, setFlipped] = useState(false);
130 const [phase, setPhase] = useState<"idle" | "loading" | "paid">("idle");
131 const timer = useRef(0);
132
133 const digits = number.replace(/\D/g, "");
134 const brand = detectBrand(digits);
135 const need = brand === "amex" ? 15 : 16;
136 const cvcLen = brand === "amex" ? 4 : 3;
137
138 const errors: Record<string, string | undefined> = {
139 number: !digits ? "Enter your card number" : digits.length < need ? "Card number is incomplete" : !luhn(digits) ? "That card number doesn’t look right" : undefined,
140 expiry: (() => {
141 const d = expiry.replace(/\D/g, "");
142 if (d.length < 4) return d.length ? "Use MM / YY" : "Enter an expiry date";
143 const m = Number(d.slice(0, 2));
144 const y = 2000 + Number(d.slice(2));
145 if (m < 1 || m > 12) return "Month must be 01–12";
146 const now = new Date();
147 if (y < now.getFullYear() || (y === now.getFullYear() && m < now.getMonth() + 1)) return "This card has expired";
148 return undefined;
149 })(),
150 cvc: cvc.length < cvcLen ? `${cvcLen} digits on the ${brand === "amex" ? "front" : "back"} of your card` : undefined,
151 name: name.trim().length < 3 || !name.includes(" ") ? "Enter the name as printed on the card" : undefined,
152 };
153 const show = (k: string) => (touched[k] ? errors[k] : undefined);
154 const touch = (k: string) => setTouched((t) => ({ ...t, [k]: true }));
155 const valid = !Object.values(errors).some(Boolean);
156
157 const submit = async (e: React.FormEvent) => {
158 e.preventDefault();
159 if (phase !== "idle") return;
160 setTouched({ number: true, expiry: true, cvc: true, name: true });
161 if (!valid) {
162 const first = (["number", "expiry", "cvc", "name"] as const).find((k) => errors[k]);
163 document.getElementById(`${uid}-${first}`)?.focus();
164 return;
165 }
166 setPhase("loading");
167 try {
168 await onSubmit?.({ number: digits, brand, expiry, cvc, name, country });
169 await new Promise((r) => (timer.current = window.setTimeout(r, 1400)));
170 setPhase("paid");
171 } catch {
172 setPhase("idle");
173 }
174 };
175
176 const numMask = useMask((d) => formatCardNumber(d, detectBrand(d)), setNumber);
177 const expMask = useMask(formatExpiry, setExpiry);
178
179 const field = (k: "number" | "expiry" | "cvc" | "name", label: string, control: ReactNode) => (
180 <div className="fd-pay-field" data-bad={show(k) ? "" : undefined} data-k={k}>
181 <label htmlFor={`${uid}-${k}`}>{label}</label>
182 <div className="fd-pay-box">{control}</div>
183 <p className="fd-pay-err" id={`${uid}-${k}-e`} role={show(k) ? "alert" : undefined}>{show(k)}</p>
184 </div>
185 );
186
187 return (
188 <form className={`fd-pay${className ? " " + className : ""}`} data-fd-theme={theme} onSubmit={submit} noValidate aria-label="Payment details">
189 <style>{css}</style>
190 <CardPreview brand={brand} number={number} name={name} expiry={expiry} cvc={cvc} flipped={flipped} />
191
192 <div className="fd-pay-fields">
193 {field(
194 "number",
195 "Card number",
196 <>
197 <input id={`${uid}-number`} inputMode="numeric" autoComplete="cc-number" placeholder="1234 1234 1234 1234" value={number} onChange={numMask} onBlur={() => touch("number")} aria-invalid={!!show("number")} aria-describedby={`${uid}-number-e`} disabled={phase !== "idle"} />
198 <span className="fd-pay-brand" aria-label={brand === "unknown" ? undefined : brand}><BrandMark brand={brand} /></span>
199 </>,
200 )}
201 <div className="fd-pay-two">
202 {field("expiry", "Expiry", <input id={`${uid}-expiry`} inputMode="numeric" autoComplete="cc-exp" placeholder="MM / YY" value={expiry} onChange={expMask} onBlur={() => touch("expiry")} aria-invalid={!!show("expiry")} aria-describedby={`${uid}-expiry-e`} disabled={phase !== "idle"} />)}
203 {field(
204 "cvc",
205 brand === "amex" ? "CID" : "CVC",
206 <input
207 id={`${uid}-cvc`}
208 inputMode="numeric"
209 autoComplete="cc-csc"
210 placeholder={brand === "amex" ? "1234" : "123"}
211 value={cvc}
212 onChange={(e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, cvcLen))}
213 onFocus={() => setFlipped(brand !== "amex")}
214 onBlur={() => {
215 setFlipped(false);
216 touch("cvc");
217 }}
218 aria-invalid={!!show("cvc")}
219 aria-describedby={`${uid}-cvc-e`}
220 disabled={phase !== "idle"}
221 />,
222 )}
223 </div>
224 {field("name", "Name on card", <input id={`${uid}-name`} autoComplete="cc-name" placeholder="Full name" value={name} onChange={(e) => setName(e.target.value)} onBlur={() => touch("name")} aria-invalid={!!show("name")} aria-describedby={`${uid}-name-e`} disabled={phase !== "idle"} />)}
225 <div className="fd-pay-field">
226 <label htmlFor={`${uid}-country`}>Billing country</label>
227 <div className="fd-pay-box fd-pay-select">
228 <select id={`${uid}-country`} autoComplete="country" value={country} onChange={(e) => setCountry(e.target.value)} disabled={phase !== "idle"}>
229 {countries.map((c) => (
230 <option key={c.code} value={c.code}>{c.name}</option>
231 ))}
232 </select>
233 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
234 </div>
235 </div>
236 </div>
237
238 <button type="submit" className="fd-pay-submit" data-phase={phase} aria-busy={phase === "loading"}>
239 <span className="fd-pay-l" data-on={phase === "idle" ? "" : undefined}>
240 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2.5" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg>
241 Pay {amountLabel}
242 </span>
243 <span className="fd-pay-l" data-on={phase === "loading" ? "" : undefined} aria-hidden="true">
244 <svg className="fd-pay-spin" width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity=".25" strokeWidth="2.5" /><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
245 Processing
246 </span>
247 <span className="fd-pay-l" data-on={phase === "paid" ? "" : undefined} aria-hidden="true">
248 <svg className="fd-pay-check" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
249 Payment confirmed
250 </span>
251 </button>
252 <p className="fd-pay-fine" role="status">{phase === "paid" ? "A receipt was sent to your email." : "Encrypted end to end. We never store your full card number."}</p>
253 </form>
254 );
255}
256
257/* ── card preview ── */
258
259const SKIN: Record<CardBrand, { a: string; b: string }> = {
260 visa: { a: "#16306B", b: "#0B1B45" },
261 mastercard: { a: "#2B2B33", b: "#121216" },
262 amex: { a: "#0F6B73", b: "#08414A" },
263 discover: { a: "#7A3B10", b: "#3F1D08" },
264 jcb: { a: "#1D4D8C", b: "#102B52" },
265 unionpay: { a: "#7B1230", b: "#3F0818" },
266 unknown: { a: "#3A3A44", b: "#1A1A20" },
267};
268
269function BrandMark({ brand, large }: { brand: CardBrand; large?: boolean }) {
270 const h = large ? 30 : 22;
271 if (brand === "visa")
272 return <svg height={h} viewBox="0 0 48 16" aria-hidden="true"><text x="0" y="14" fill={large ? "#fff" : "#1A3F9E"} style={{ font: "italic 800 17px/1 ui-sans-serif,system-ui,sans-serif", letterSpacing: "-.5px" }}>VISA</text></svg>;
273 if (brand === "mastercard")
274 return <svg height={h} viewBox="0 0 32 20" aria-hidden="true"><circle cx="11" cy="10" r="9" fill="#EB001B" /><circle cx="21" cy="10" r="9" fill="#F79E1B" /><path d="M16 3.1a9 9 0 0 1 0 13.8 9 9 0 0 1 0-13.8z" fill="#FF5F00" /></svg>;
275 if (brand === "amex")
276 return <svg height={h} viewBox="0 0 44 20" aria-hidden="true"><rect width="44" height="20" rx="4" fill={large ? "#fff" : "#0A6CB4"} /><text x="22" y="14" textAnchor="middle" fill={large ? "#0F6B73" : "#fff"} style={{ font: "800 10px/1 ui-sans-serif,system-ui,sans-serif", letterSpacing: ".5px" }}>AMEX</text></svg>;
277 if (brand === "discover")
278 return <svg height={h} viewBox="0 0 64 20" aria-hidden="true"><text x="0" y="14" fill={large ? "#fff" : "#2B2B33"} style={{ font: "800 11px/1 ui-sans-serif,system-ui,sans-serif", letterSpacing: ".2px" }}>DISCOVER</text><circle cx="58" cy="10" r="5" fill="#F58220" /></svg>;
279 if (brand === "jcb" || brand === "unionpay")
280 return <svg height={h} viewBox="0 0 44 20" aria-hidden="true"><rect x="1" y="1" width="42" height="18" rx="4" fill="none" stroke={large ? "#fff" : "#6B6B76"} strokeWidth="1.5" /><text x="22" y="14" textAnchor="middle" fill={large ? "#fff" : "#6B6B76"} style={{ font: "800 9px/1 ui-sans-serif,system-ui,sans-serif" }}>{brand === "jcb" ? "JCB" : "UnionPay"}</text></svg>;
281 return (
282 <svg width={large ? 32 : 24} height={large ? 22 : 16} viewBox="0 0 24 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true" opacity=".55"><rect x="1" y="1" width="22" height="14" rx="3" /><path d="M1 6h22M5 11h4" /></svg>
283 );
284}
285
286export function CardPreview({ brand, number, name, expiry, cvc, flipped }: { brand: CardBrand; number: string; name: string; expiry: string; cvc: string; flipped: boolean }) {
287 const skin = SKIN[brand];
288 const mask = brand === "amex" ? "•••• •••••• •••••" : "•••• •••• •••• ••••";
289 const digits = number.replace(/\D/g, "");
290 const shown = number + mask.slice(number.length);
291 return (
292 <div className="fd-pay-scene" aria-hidden="true">
293 <div className="fd-pay-card" data-flip={flipped || undefined} style={{ ["--c1" as string]: skin.a, ["--c2" as string]: skin.b }}>
294 <div className="fd-pay-face fd-pay-front">
295 <div className="fd-pay-top">
296 <svg width="40" height="30" viewBox="0 0 40 30"><rect width="40" height="30" rx="6" fill="#E3C46F" /><path d="M0 10h14v10H0M26 10h14v10H26M14 0v30M26 0v30" stroke="#B8963A" strokeWidth="1.4" fill="none" /></svg>
297 <span key={brand} className="fd-pay-logo"><BrandMark brand={brand} large /></span>
298 </div>
299 <div className="fd-pay-num" data-empty={!digits || undefined}>{shown}</div>
300 <div className="fd-pay-bot">
301 <div><small>Cardholder</small><span>{name || "FULL NAME"}</span></div>
302 <div><small>Expires</small><span>{expiry.replace(/ /g, "") || "MM/YY"}</span></div>
303 </div>
304 </div>
305 <div className="fd-pay-face fd-pay-back">
306 <div className="fd-pay-stripe" />
307 <div className="fd-pay-sig"><span>{name ? "" : ""}</span><b>{cvc || "•••"}</b></div>
308 <p>Authorized signature · not valid unless signed</p>
309 </div>
310 </div>
311 </div>
312 );
313}
314
315const css = `
316:where(.fd-pay,.fd-pay *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
317:where(.fd-pay,.fd-pay *)::before,:where(.fd-pay,.fd-pay *)::after{box-sizing:border-box}
318:where(.fd-pay svg){display:block;flex-shrink:0}
319:where(.fd-pay button,.fd-pay input,.fd-pay select){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0;outline:0}
320.fd-pay{
321 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
322 --p-bg:#fff;--p-fg:#0B0B0F;--p-mut:#6B6B76;--p-line:rgba(11,11,15,.16);--p-line-h:rgba(11,11,15,.28);--p-accent:#007AFF;--p-err:#D70015;--p-ok:#1FA45B;
323 display:flex;flex-direction:column;gap:20px;width:100%;max-width:420px;
324 font:400 15px/1.4 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.005em;color:var(--p-fg);
325}
326[data-fd-theme="dark"] .fd-pay,.fd-pay[data-fd-theme="dark"]{--p-bg:#16161B;--p-fg:#F5F5F7;--p-mut:#9A9AA6;--p-line:rgba(255,255,255,.14);--p-line-h:rgba(255,255,255,.26);--p-accent:#0A84FF;--p-err:#FF6A60}
327.fd-pay-scene{perspective:1100px;display:grid;place-items:center;padding:4px 0 8px}
328.fd-pay-card{position:relative;width:100%;max-width:340px;aspect-ratio:1.586;transform-style:preserve-3d;transition:transform var(--fd-dur-4) cubic-bezier(.34,1.3,.64,1)}
329.fd-pay-card[data-flip]{transform:rotateY(180deg)}
330.fd-pay-face{position:absolute;inset:0;border-radius:20px;overflow:hidden;backface-visibility:hidden;-webkit-backface-visibility:hidden;color:#fff;
331 background:radial-gradient(120% 90% at 0% 0%,rgba(255,255,255,.16),transparent 55%),linear-gradient(145deg,var(--c1),var(--c2));
332 box-shadow:0 1px 0 rgba(255,255,255,.2) inset,0 0 0 1px rgba(255,255,255,.08) inset,0 2px 4px rgba(0,0,0,.2),0 24px 40px -18px rgba(0,0,0,.55);transition:background .5s var(--fd-ease-out)}
333.fd-pay-front{display:flex;flex-direction:column;justify-content:space-between;padding:20px 22px}
334.fd-pay-top{display:flex;justify-content:space-between;align-items:flex-start}
335.fd-pay-logo{display:block;animation:fd-pay-logo var(--fd-dur-3) var(--fd-spring)}
336.fd-pay-num{font:500 19px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.06em;text-shadow:0 1px 2px rgba(0,0,0,.3);white-space:nowrap;font-variant-numeric:tabular-nums}
337.fd-pay-num[data-empty]{opacity:.75}
338.fd-pay-bot{display:flex;justify-content:space-between;gap:12px;font:500 13px/1.2 ui-monospace,"SF Mono",Menlo,monospace;text-transform:uppercase;letter-spacing:.04em}
339.fd-pay-bot div{display:flex;flex-direction:column;gap:4px;min-width:0}.fd-pay-bot div:last-child{text-align:right;flex:none}
340.fd-pay-bot small{font-size:9px;opacity:.65;letter-spacing:.08em}.fd-pay-bot span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
341.fd-pay-back{transform:rotateY(180deg);padding-top:24px}
342.fd-pay-stripe{height:40px;background:#0B0B0F;margin-bottom:20px}
343.fd-pay-sig{display:flex;align-items:center;margin:0 20px;height:36px;border-radius:4px;background:repeating-linear-gradient(135deg,#f4f4f2 0 6px,#e9e9e6 6px 12px);color:#0B0B0F}
344.fd-pay-sig span{flex:1}.fd-pay-sig b{padding:0 12px;font:700 16px/1 ui-monospace,"SF Mono",Menlo,monospace;font-style:italic;letter-spacing:.1em;background:#fff;height:100%;display:grid;place-items:center;border-radius:0 4px 4px 0}
345.fd-pay-back p{margin:12px 20px 0;font-size:9px;opacity:.6;letter-spacing:.04em;text-transform:uppercase}
346.fd-pay-fields{display:flex;flex-direction:column;gap:14px}
347.fd-pay-two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
348.fd-pay-field label{display:block;margin:0 0 6px 2px;font-size:13px;font-weight:500;line-height:1.2;color:var(--p-mut)}
349.fd-pay-box{position:relative;display:flex;align-items:center;height:48px;border-radius:12px;background:var(--p-bg);box-shadow:0 0 0 1px var(--p-line),0 1px 2px rgba(11,11,15,.04);transition:box-shadow var(--fd-dur-2) var(--fd-ease-out)}
350@media (hover:hover){.fd-pay-box:hover{box-shadow:0 0 0 1px var(--p-line-h),0 1px 2px rgba(11,11,15,.04)}}
351.fd-pay-box:focus-within{box-shadow:0 0 0 1.5px var(--p-accent),0 0 0 5px color-mix(in srgb,var(--p-accent) 18%,transparent)}
352.fd-pay-field[data-bad] .fd-pay-box{box-shadow:0 0 0 1.5px var(--p-err)}
353.fd-pay-field[data-bad] .fd-pay-box:focus-within{box-shadow:0 0 0 1.5px var(--p-err),0 0 0 5px color-mix(in srgb,var(--p-err) 18%,transparent)}
354.fd-pay input,.fd-pay select{width:100%;height:100%;padding:0 14px;color:var(--p-fg);font-family:inherit;font-size:15px;line-height:1.2;font-weight:400;letter-spacing:inherit;border-radius:12px;caret-color:var(--p-accent);min-width:0}
355.fd-pay input[inputmode="numeric"]{font-variant-numeric:tabular-nums}
356.fd-pay input::placeholder{color:var(--p-mut);opacity:.6}
357.fd-pay input:disabled,.fd-pay select:disabled{opacity:.6;cursor:not-allowed}
358.fd-pay-brand{display:grid;place-items:center;min-width:44px;padding-right:12px;color:var(--p-mut)}
359.fd-pay-brand>*{animation:fd-pay-logo var(--fd-dur-3) var(--fd-spring)}
360.fd-pay-select{color:var(--p-mut)}.fd-pay-select select{padding-right:40px;cursor:pointer;color:var(--p-fg)}.fd-pay-select select option{color:#0B0B0F;background:#fff}
361.fd-pay-select svg{position:absolute;right:14px;pointer-events:none}
362.fd-pay-err{min-height:0;margin:0;padding:0 2px;font-size:12px;line-height:1.3;color:var(--p-err);max-height:0;overflow:hidden;opacity:0;transform:translateY(-3px);transition:max-height var(--fd-dur-3) var(--fd-ease-out),opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-2) var(--fd-ease-out),padding var(--fd-dur-2)}
363.fd-pay-field[data-bad] .fd-pay-err{max-height:40px;opacity:1;transform:none;padding-top:6px}
364.fd-pay-submit{position:relative;display:grid;place-items:center;width:100%;height:52px;margin-top:4px;border-radius:9999px;cursor:pointer;overflow:hidden;
365 background:var(--p-accent);color:#fff;font-family:inherit;font-size:17px;font-weight:500;line-height:1;letter-spacing:-.01em;
366 box-shadow:0 1px 0 rgba(255,255,255,.22) inset,0 1px 2px rgba(0,40,120,.35),0 10px 22px -12px var(--p-accent);
367 transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring),background-color var(--fd-dur-2) var(--fd-ease-out),filter var(--fd-dur-1)}
368@media (hover:hover){.fd-pay-submit:hover{filter:brightness(1.08)}}
369.fd-pay-submit:active{transform:scale(.97);transition:transform var(--fd-dur-1) var(--fd-ease-out)}
370.fd-pay-submit:focus-visible{outline:2px solid color-mix(in srgb,var(--p-accent) 70%,transparent);outline-offset:3px}
371.fd-pay-submit[data-phase="paid"]{background:var(--p-ok)}
372.fd-pay-l{grid-area:1/1;display:inline-flex;align-items:center;gap:8px;opacity:0;transform:translateY(8px) scale(.9);transition:opacity var(--fd-dur-2) var(--fd-ease-in-out),transform var(--fd-dur-3) var(--fd-ease-out);pointer-events:none}
373.fd-pay-l[data-on]{opacity:1;transform:none}
374.fd-pay-spin{animation:fd-pay-rot .8s linear infinite}
375.fd-pay-check path{stroke-dasharray:24;stroke-dashoffset:24;animation:fd-pay-draw 380ms var(--fd-ease-out) 120ms forwards}
376.fd-pay-fine{text-align:center;font-size:12px;line-height:1.4;color:var(--p-mut)}
377@keyframes fd-pay-rot{to{transform:rotate(360deg)}}
378@keyframes fd-pay-draw{to{stroke-dashoffset:0}}
379@keyframes fd-pay-logo{from{opacity:0;transform:scale(.7)}}
380@media (max-width:420px){.fd-pay-num{font-size:16px}.fd-pay-front{padding:16px 18px}}
381@media (prefers-reduced-motion:reduce){.fd-pay-card{transition:none}.fd-pay-logo,.fd-pay-brand>*{animation:none}.fd-pay-l,.fd-pay-submit,.fd-pay-submit:active{transition:none;transform:none}.fd-pay-spin{animation:none}.fd-pay-check path{animation:none;stroke-dashoffset:0}.fd-pay-card[data-flip]{transform:rotateY(180deg)}}
382@media (prefers-contrast:more){.fd-pay-box{box-shadow:0 0 0 1px currentColor}}
383`;
384
385/* ───────────────────────────── demo ───────────────────────────── */
386
387const TEST = [
388 ["Visa", "4242424242424242"],
389 ["Mastercard", "5555555555554444"],
390 ["Amex", "378282246310005"],
391];
392
393export function Demo() {
394 const [theme, setTheme] = useState<"light" | "dark">("light");
395 return (
396 <div className="fd-pay-demo" data-fd-theme={theme}>
397 <style>{demoCss}</style>
398 <header className="fd-pay-demo-bar">
399 <span className="fd-pay-demo-brand"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 17l8-4.5V6a2 2 0 1 1 4 0v6.5l6 3.5v2l-6-1.5V19l2 1v1.5l-3.5-1L10 21.5V20l2-1v-3.5L3 19z" /></svg>Wayfarer</span>
400 <ol className="fd-pay-demo-steps" aria-label="Checkout progress"><li>Trip</li><li>Travellers</li><li aria-current="step">Payment</li></ol>
401 <button type="button" className="fd-pay-demo-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"}</button>
402 </header>
403
404 <main className="fd-pay-demo-main">
405 <section className="fd-pay-demo-pane" aria-labelledby="fd-pay-h">
406 <h1 id="fd-pay-h">Payment</h1>
407 <p className="fd-pay-demo-sub">Pay securely to confirm your booking.</p>
408 <div className="fd-pay-demo-test" aria-label="Fill with a test card">
409 <span>Try a test card</span>
410 {TEST.map(([n, v]) => (
411 <button key={n} type="button" onClick={(e) => fill(e.currentTarget, v)}>{n}</button>
412 ))}
413 </div>
414 <PaymentForm amountLabel="€1,248.60" defaultName="Sofia Lindqvist" theme={theme} />
415 </section>
416
417 <aside className="fd-pay-demo-sum" aria-label="Booking summary">
418 <div className="fd-pay-demo-pic" aria-hidden="true">
419 <svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice" width="100%" height="100%"><rect width="400" height="180" fill="#FBD9B5" /><circle cx="290" cy="64" r="34" fill="#F7A072" /><path d="M0 180V118l60-28 50 26 70-44 60 40 70-30 90 40v58z" fill="#E9825B" opacity=".85" /><path d="M0 180v-34l70-22 60 24 80-30 70 28 120-26v60z" fill="#C45A3C" /></svg>
420 <span>Lisbon, Portugal</span>
421 </div>
422 <h2>Casa do Alfama · 4 nights</h2>
423 <p className="fd-pay-demo-meta">Thu 12 Nov – Mon 16 Nov · 2 adults</p>
424 <dl>
425 <div><dt>Room, 4 nights × €142.00</dt><dd>€568.00</dd></div>
426 <div><dt>Flights LHR → LIS, return</dt><dd>€612.00</dd></div>
427 <div><dt>City tax</dt><dd>€16.00</dd></div>
428 <div><dt>Service fee</dt><dd>€52.60</dd></div>
429 <div data-total><dt>Total (EUR)</dt><dd>€1,248.60</dd></div>
430 </dl>
431 <p className="fd-pay-demo-cancel"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>Free cancellation until 9 Nov</p>
432 </aside>
433 </main>
434 </div>
435 );
436}
437
438/** Demo helper: type a number into the card field of the mounted form. */
439function fill(btn: HTMLElement, digits: string) {
440 const input = btn.closest(".fd-pay-demo-pane")?.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');
441 if (!input) return;
442 const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
443 setter?.call(input, digits);
444 input.dispatchEvent(new Event("input", { bubbles: true }));
445 input.focus();
446}
447
448const demoCss = `
449:where(.fd-pay-demo,.fd-pay-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
450:where(.fd-pay-demo,.fd-pay-demo *)::before,:where(.fd-pay-demo,.fd-pay-demo *)::after{box-sizing:border-box}
451:where(.fd-pay-demo svg){display:block;flex-shrink:0}
452:where(.fd-pay-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
453.fd-pay-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif;
454 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;
455 --d-bg:#F6F4F1;--d-card:#fff;--d-ink:#0B0B0F;--d-mut:#6B6B76;--d-line:rgba(11,11,15,.08);--d-accent:#007AFF;
456 min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.45 var(--d-font);-webkit-font-smoothing:antialiased;transition:background-color var(--fd-dur-2) var(--fd-ease-out)}
457.fd-pay-demo[data-fd-theme="dark"]{--d-bg:#09090B;--d-card:#111115;--d-ink:#F5F5F7;--d-mut:#9A9AA6;--d-line:rgba(255,255,255,.09);--d-accent:#0A84FF}
458.fd-pay-demo-bar{display:flex;align-items:center;gap:24px;height:60px;padding:0 clamp(16px,4vw,40px)}
459.fd-pay-demo-brand{display:flex;align-items:center;gap:8px;font:700 17px/1 var(--d-disp);letter-spacing:-.03em}
460.fd-pay-demo-steps{display:flex;gap:20px;margin:0 auto;font:500 13px/1 var(--d-font);color:var(--d-mut)}
461.fd-pay-demo-steps li{display:flex;align-items:center;gap:20px}.fd-pay-demo-steps li:not(:last-child)::after{content:"";width:24px;height:1px;background:var(--d-line)}
462.fd-pay-demo-steps li[aria-current]{color:var(--d-ink)}
463.fd-pay-demo-theme{cursor:pointer;height:32px;padding:0 14px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 13px/1 var(--d-font);color:var(--d-mut)}
464.fd-pay-demo-theme:hover{color:var(--d-ink)}.fd-pay-demo-theme:focus-visible,.fd-pay-demo-test button:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
465.fd-pay-demo-main{display:grid;grid-template-columns:minmax(0,460px) minmax(0,360px);gap:32px;justify-content:center;align-items:start;padding:24px clamp(16px,4vw,40px) 64px}
466.fd-pay-demo-pane{padding:32px;border-radius:28px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 1px 2px rgba(0,0,0,.04),0 32px 64px -40px rgba(0,0,0,.3)}
467.fd-pay-demo-pane h1{font:600 24px/1.2 var(--d-disp);letter-spacing:-.02em}
468.fd-pay-demo-sub{margin-top:6px;font:400 14px/1.4 var(--d-font);color:var(--d-mut)}
469.fd-pay-demo-test{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:20px 0 20px;font:400 13px/1 var(--d-font);color:var(--d-mut)}
470.fd-pay-demo-test button{cursor:pointer;height:26px;padding:0 10px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 12px/1 var(--d-font);color:var(--d-ink)}
471.fd-pay-demo-test button:hover{background:var(--d-line)}
472.fd-pay-demo-sum{position:sticky;top:24px;padding:20px;border-radius:24px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line)}
473.fd-pay-demo-pic{position:relative;height:150px;border-radius:14px;overflow:hidden}
474.fd-pay-demo-pic span{position:absolute;left:12px;bottom:12px;height:24px;display:inline-flex;align-items:center;padding:0 10px;border-radius:9999px;background:rgba(255,255,255,.9);color:#0B0B0F;font:600 12px/1 var(--d-font)}
475.fd-pay-demo-sum h2{margin-top:16px;font:600 17px/1.25 var(--d-font);letter-spacing:-.01em}
476.fd-pay-demo-meta{margin-top:4px;font:400 14px/1.4 var(--d-font);color:var(--d-mut)}
477.fd-pay-demo-sum dl{display:flex;flex-direction:column;gap:12px;margin-top:20px;padding-top:16px;border-top:1px solid var(--d-line);font:400 14px/1.3 var(--d-font)}
478.fd-pay-demo-sum dl div{display:flex;justify-content:space-between;gap:16px}.fd-pay-demo-sum dt{color:var(--d-mut)}.fd-pay-demo-sum dd{font-variant-numeric:tabular-nums}
479.fd-pay-demo-sum dl div[data-total]{margin-top:4px;padding-top:16px;border-top:1px solid var(--d-line);font:600 17px/1.2 var(--d-font)}.fd-pay-demo-sum dl div[data-total] dt{color:var(--d-ink)}
480.fd-pay-demo-cancel{display:flex;align-items:center;gap:8px;margin-top:16px;font:500 13px/1 var(--d-font);color:#1C9A44}
481@media (max-width:900px){.fd-pay-demo-main{grid-template-columns:minmax(0,460px)}.fd-pay-demo-sum{position:static;order:-1}.fd-pay-demo-steps{display:none}}
482@media (max-width:520px){.fd-pay-demo-pane{padding:20px;border-radius:22px}}
483`;

More Forms. Same file contract.

Input Field

A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.

OTP Input

Six-digit code entry with auto-advance, paste and SMS autofill, error shake and a success cascade.

Multi-step Signup

A three-step onboarding flow with progress, validation, animated transitions and a review step.