Input Field
A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.
Card entry with brand detection, live formatting, Luhn validation and a preview that flips to the CVC.
npx shadcn@latest add https://freedesignmd.com/r/payment-form.json1/**2 * PaymentForm — card entry with brand detection, live formatting, Luhn and3 * expiry validation, a country select and a live card preview that flips to4 * 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; falls13 * 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 and16 * 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 <input207 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 Processing246 </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 confirmed250 </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`;A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.
Six-digit code entry with auto-advance, paste and SMS autofill, error shake and a success cascade.
A three-step onboarding flow with progress, validation, animated transitions and a review step.