Forms and inputsPayment form
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callPayment form
Card details with live formatting, brand detection and a clear total. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add payment-formReact
npx designme add payment-form --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Payment form is a plan checkout with a Monthly and Yearly switch, card fields that format themselves as you type, and a summary that shows the seats, the yearly discount and the amount due today. The total rolls to the new price when the billing period changes, and the Pay button shows Processing and then Payment successful. It covers the moment someone upgrades to a paid plan for your agent product.
- When a team upgrades to a paid plan priced per seat
- When you want to show the saving from yearly billing next to the monthly price
- When a quick card checkout should catch typos before the payment is sent
- When the price should update in place as the person switches billing period
- The card number groups itself in fours, and a VISA, MC or AMEX badge appears from the first digits.
- Expiry formats as MM / YY, and a finished number or expiry moves focus to the next field.
- The summary multiplies
seatsbyprice, appliesyearlyDiscounton yearly billing, and the total animates to the new amount. - On submit it checks for 16 digits, a full expiry and a CVC of at least three digits, and marks any field that fails.
onPayreceives the formatted card, the period and the total; a promise keeps the button on Processing until it settles.- A rejected
onPayshows its message, orerrorLabel, under the button.
- Card inputs use
autocompletevaluescc-number,cc-expandcc-cscwith a numeric keypad, so browsers and password managers can fill them. - Fields that fail the check set
aria-invalid, and a payment error renders withrole="alert". - The Monthly and Yearly switch is a
grouplabelled Billing, witharia-pressedon each option. - The Pay button is an
aria-live="polite"region, so Processing and Payment successful are announced.
Usage
Import it and drive it from your own state.
Import
import { PaymentForm } from '@/components/designme/payment-form/PaymentForm';Example
The demo above as a React component, 28 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { PaymentForm, type PaymentCard } from './PaymentForm';
import { useStory } from '../lib/useScript';
/* ---------- Payment form: a test card number types itself in; finish the expiry and CVC and pay ---------- */
export default function PaymentFormDemo() {
const [card, setCard] = useState<PaymentCard>({ number: '', expiry: '', cvc: '' });
// the preview types a test card on its own; clicking, focusing or typing in it takes over, and what it had typed goes
const ref = useRef<HTMLDivElement>(null);
useStory(ref, async ({ wait, taken }) => {
await wait(1000);
let typing = true;
taken(() => { if (typing) setCard(c => ({ ...c, number: '' })); });
for (const ch of '4242424242424242') { setCard(c => ({ ...c, number: c.number + ch })); await wait(45); }
typing = false;
typing = false;
});
return (
<div ref={ref} className="dm-pyx-wrap">
<PaymentForm card={card} onCardChange={setCard}
onPay={() => new Promise<void>(res => setTimeout(res, 1200))} />
<span className="dm-muted dm-pyx-n">Demo only. Nothing is charged.</span>
</div>
);
}
Props
Everything PaymentForm takes.
| Prop | Type | What it does |
|---|---|---|
plan | ReactNode | Defaults to "Pro plan" |
description | ReactNode | Defaults to "For growing design teams" |
seats | number | Defaults to 3 |
seatLabel | string | Defaults to "editors" |
price | number | Price per seat per month. Defaults to 24 |
currency | string | Put before every amount. Defaults to "$" |
yearlyDiscount | number | Taken off a yearly plan, 0 to 1. Defaults to 0.2 |
period | PaymentPeriod | Monthly or yearly. Leave it out to let the switch hold its own |
defaultPeriod | PaymentPeriod | |
onPeriodChange | (period: PaymentPeriod) => void | |
card | PaymentCard | The card fields. Leave it out to let the fields hold their own |
defaultCard | PaymentCard | |
onCardChange | (card: PaymentCard) => void | |
onPay | (payment: { card: PaymentCard; period: PaymentPeriod; total: number }) => void | Promise<void> | Called once the card looks complete. Return a promise and the button shows Processing until it settles; if it rejects, its message shows under the button |
payLabel | string | |
processingLabel | string | |
paidLabel | string | |
discountLabel | string | |
dueLabel | string | |
note | ReactNode | A quiet line under the button, like "Nothing is charged until your trial ends" |
errorLabel | string | Shown when onPay rejects without a message of its own |
className | string |
Code
The full source, as the CLI installs it.
PaymentForm.tsxReact · 150 lines
'use client';
import { useRef, useState, type FormEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Seg } from '../lib/motion';
import '../designme.css';
import './payment-form.css';
export type PaymentPeriod = 'monthly' | 'yearly';
/** The card as typed, already formatted: "4242 4242 4242 4242", "12 / 28", "123" */
export interface PaymentCard {
number: string;
expiry: string;
cvc: string;
}
export interface PaymentFormProps {
/** Defaults to "Pro plan" */
plan?: ReactNode;
/** Defaults to "For growing design teams" */
description?: ReactNode;
/** Defaults to 3 */
seats?: number;
/** Defaults to "editors" */
seatLabel?: string;
/** Price per seat per month. Defaults to 24 */
price?: number;
/** Put before every amount. Defaults to "$" */
currency?: string;
/** Taken off a yearly plan, 0 to 1. Defaults to 0.2 */
yearlyDiscount?: number;
/** Monthly or yearly. Leave it out to let the switch hold its own */
period?: PaymentPeriod;
defaultPeriod?: PaymentPeriod;
onPeriodChange?: (period: PaymentPeriod) => void;
/** The card fields. Leave it out to let the fields hold their own */
card?: PaymentCard;
defaultCard?: PaymentCard;
onCardChange?: (card: PaymentCard) => void;
/** Called once the card looks complete. Return a promise and the button shows Processing until it settles; if it rejects, its message shows under the button */
onPay?: (payment: { card: PaymentCard; period: PaymentPeriod; total: number }) => void | Promise<void>;
payLabel?: string;
processingLabel?: string;
paidLabel?: string;
discountLabel?: string;
dueLabel?: string;
/** A quiet line under the button, like "Nothing is charged until your trial ends" */
note?: ReactNode;
/** Shown when onPay rejects without a message of its own */
errorLabel?: string;
className?: string;
}
type CardField = keyof PaymentCard;
const EMPTY: PaymentCard = { number: '', expiry: '', cvc: '' };
const digits = (s: string) => s.replace(/\D/g, '');
const asNumber = (s: string) => digits(s).slice(0, 16).replace(/(.{4})/g, '$1 ').trim();
const asExpiry = (s: string) => { let d = digits(s).slice(0, 4); if (d.length === 1 && +d > 1) d = '0' + d; return d.length > 2 ? d.slice(0, 2) + ' / ' + d.slice(2) : d; };
const asCvc = (s: string) => digits(s).slice(0, 4);
const BRANDS: { test: RegExp; name: string; color: string }[] = [
{ test: /^4/, name: 'VISA', color: '#1a1f71' }, { test: /^5[1-5]/, name: 'MC', color: '#eb001b' }, { test: /^3[47]/, name: 'AMEX', color: '#2e77bb' }
];
/** A plan checkout: monthly or yearly, a card that formats itself as you type, and a total that rolls to the new price. */
export function PaymentForm({
plan = 'Pro plan', description = 'For growing design teams', seats = 3, seatLabel = 'editors', price = 24, currency = '$', yearlyDiscount = 0.2,
period, defaultPeriod = 'yearly', onPeriodChange, card, defaultCard = EMPTY, onCardChange, onPay, payLabel = 'Pay', processingLabel = 'Processing',
paidLabel = 'Payment successful', discountLabel = 'Yearly discount', dueLabel = 'Due today', note, errorLabel = "The payment didn't go through", className
}: PaymentFormProps) {
const [per, setPer] = useControllable(period, defaultPeriod, onPeriodChange);
const [raw, setCard] = useControllable(card, defaultCard, onCardChange);
const [bad, setBad] = useState<CardField[]>([]);
const [state, setState] = useState<'idle' | 'processing' | 'paid'>('idle');
const [error, setError] = useState('');
const expiry = useRef<HTMLInputElement>(null), cvc = useRef<HTMLInputElement>(null);
const c = { number: asNumber(raw.number), expiry: asExpiry(raw.expiry), cvc: asCvc(raw.cvc) };
// the brand badge keeps its last name while it fades out
const brand = BRANDS.find(b => b.test.test(digits(c.number)));
const [shownBrand, setShownBrand] = useState(brand);
if (brand && brand !== shownBrand) setShownBrand(brand);
const yearly = per === 'yearly', month = price * seats;
const subtotal = yearly ? month * 12 : month, discount = yearly ? month * 12 * yearlyDiscount : 0, total = subtotal - discount;
const shown = useTween(total, 600);
const money = (n: number) => `${currency}${n.toFixed(2)}`;
const edit = (key: CardField, value: string) => {
const next = { ...c, [key]: key === 'number' ? asNumber(value) : key === 'expiry' ? asExpiry(value) : asCvc(value) };
setCard(next); setBad(b => b.filter(k => k !== key)); setError('');
// a finished number or expiry moves on to the next field
if (key === 'number' && digits(next.number).length === 16 && digits(c.number).length < 16) expiry.current?.focus({ preventScroll: true });
if (key === 'expiry' && digits(next.expiry).length === 4 && digits(c.expiry).length < 4) cvc.current?.focus({ preventScroll: true });
};
const pay = async (e: FormEvent) => {
e.preventDefault();
if (state !== 'idle') return;
const wrong = ([['number', digits(c.number).length === 16], ['expiry', /^\d\d \/ \d\d$/.test(c.expiry)], ['cvc', c.cvc.length >= 3]] as const).filter(([, ok]) => !ok).map(([k]) => k);
setBad(wrong);
if (wrong.length) return;
setState('processing'); setError('');
try { await onPay?.({ card: c, period: per, total }); } catch (err) { setState('idle'); setError(err instanceof Error && err.message ? err.message : errorLabel); return; }
setState('paid');
};
const fieldClass = (k: CardField) => cx('dm-pyx-f', bad.includes(k) && 'dm-bad');
return (
<form data-dm="" className={cx('dm-pyx dm-panel', className)} noValidate onSubmit={pay}>
<div className="dm-pyx-h">
<div><b>{plan}</b><span className="dm-muted">{description}</span></div>
<Seg className="dm-pyx-per" label="Billing" value={per} onChange={setPer} options={[{ value: 'monthly', label: 'Monthly' }, { value: 'yearly', label: 'Yearly' }]} />
</div>
<label className={fieldClass('number')}>
<span>Card number</span>
<div className="dm-pyx-in">
<input className="dm-pyx-cn" name="cc-number" inputMode="numeric" autoComplete="cc-number" placeholder="1234 1234 1234 1234" aria-invalid={bad.includes('number') || undefined}
value={c.number} onChange={e => edit('number', e.target.value)} />
<span className={cx('dm-pyx-br', brand && 'dm-on')} style={shownBrand ? { background: shownBrand.color, color: '#fff' } : undefined} aria-hidden={!brand}>{shownBrand?.name}</span>
</div>
</label>
<div className="dm-pyx-2">
<label className={fieldClass('expiry')}>
<span>Expiry</span>
<div className="dm-pyx-in"><input ref={expiry} className="dm-pyx-ex" name="cc-exp" inputMode="numeric" autoComplete="cc-exp" placeholder="MM / YY" aria-invalid={bad.includes('expiry') || undefined}
value={c.expiry} onChange={e => edit('expiry', e.target.value)} /></div>
</label>
<label className={fieldClass('cvc')}>
<span>CVC</span>
<div className="dm-pyx-in"><input ref={cvc} className="dm-pyx-cv" name="cc-csc" inputMode="numeric" autoComplete="cc-csc" placeholder="123" maxLength={4} aria-invalid={bad.includes('cvc') || undefined}
value={c.cvc} onChange={e => edit('cvc', e.target.value)} /></div>
</label>
</div>
<div className="dm-pyx-sum">
<div><span>{seats} {seatLabel} × <span className="dm-num dm-pyx-u">{currency}{price}{yearly && ' × 12'}</span></span><span className="dm-num dm-pyx-s">{money(subtotal)}</span></div>
<div className={cx('dm-pyx-dc', !yearly && 'dm-off')}><span>{discountLabel}</span><span className="dm-num dm-pyx-d">−{money(discount)}</span></div>
<div className="dm-pyx-t"><b>{dueLabel}</b><b className="dm-num dm-pyx-tot">{money(shown)}</b></div>
</div>
<button type="submit" className={cx('dm-pill dm-accent dm-pyx-go', state === 'paid' && 'dm-ok')} aria-live="polite">
{state === 'processing' ? <><span className="dm-spin" style={{ borderColor: 'rgba(255,255,255,.3)', borderTopColor: '#fff' }} aria-hidden="true" />{processingLabel}</>
: state === 'paid' ? <><Icon name="check" size="xs" className="dm-enter" />{paidLabel}</>
: <><Icon name="lock" size="xs" />{payLabel} <span className="dm-num dm-pyx-gt">{money(shown)}</span></>}
</button>
{error ? <span className="dm-muted dm-pyx-n" role="alert" style={{ color: 'var(--dm-bad)' }}>{error}</span> : note && <span className="dm-muted dm-pyx-n">{note}</span>}
</form>
);
}
payment-form.cssReact · 36 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Payment form ---------- */
.dm-pyx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);max-width:360px;margin:0 auto;width:100%}
.dm-pyx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.dm-pyx-h>div:first-child{display:flex;flex-direction:column;font-size:14px}
.dm-pyx-h b{font-weight:500}
.dm-pyx-h .dm-muted{font-size:12px}
.dm-pyx-per button{font-size:12px;height:26px;padding:0 9px}
/* the card's fields have no box: the text lines up with its label, with room above and below it, over a dimmed line that
steps up while the field has the focus. Left, a filled field looks like an empty one */
.dm-pyx-f{display:flex;flex-direction:column;gap:6px;font-size:12.5px;font-weight:500;min-width:0}
/* each field is a full outline like every other field: a dimmed grey edge, empty or filled, one step stronger only while it
has the focus */
.dm-pyx-in{display:flex;align-items:center;gap:var(--dm-s-2);height:var(--dm-field-h);padding:0 var(--dm-s-2) 0 var(--dm-s-3);border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-pyx-in{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-pyx-in:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
/* a field marked wrong keeps its red line, focused or not, until it is fixed */
.dm-pyx-f.dm-bad .dm-pyx-in{box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-pyx-in input{flex:1;min-width:0;height:100%;padding:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;font-weight:400;font-variant-numeric:tabular-nums;color:var(--dm-fg)}
.dm-pyx-in input::placeholder{color:var(--dm-muted)}
.dm-pyx-br{font-size:10px;font-weight:500;letter-spacing:.04em;padding:3px 6px;border-radius:var(--dm-r-xs);opacity:0;transform:scale(.8);transition:opacity .3s,transform .35s var(--dm-ease)}
.dm-pyx-br.dm-on{opacity:1;transform:none}
.dm-pyx-2{display:grid;grid-template-columns:1fr 1fr;gap:var(--dm-s-3)}
/* the details: plain rows after a line, no panel */
.dm-pyx-sum{display:flex;flex-direction:column;gap:6px;margin-top:var(--dm-s-1);padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12.5px;color:var(--dm-fg2)}
.dm-pyx-sum>div{display:flex;justify-content:space-between}
.dm-pyx-dc{color:var(--dm-ok);transition:opacity .3s}
.dm-pyx-dc.dm-off{opacity:.3;text-decoration:line-through}
.dm-pyx-t{padding-top:6px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);color:var(--dm-fg);font-size:13.5px}
.dm-pyx-t b{font-weight:500}
.dm-pyx-go{height:var(--dm-field-h);width:100%}
.dm-pyx-go.dm-ok{background:var(--dm-ok)}
.dm-pyx-n{font-size:11.5px;text-align:center}
/* on the site the form stands alone, with the demo line under it, outside the form */
.dm-pyx-wrap{display:flex;flex-direction:column;align-items:center;gap:var(--dm-s-3);width:100%;max-width:360px;margin:0 auto}
payment-form.htmlHTML · 13 lines
<div class="card" data-c="payment-form">
<div class="stage-in">
<div class="pyx-wrap"><form class="pyx panel" novalidate>
<div class="pyx-h"><div><b>Pro plan</b><span class="muted">For growing design teams</span></div><div class="seg2 pyx-per" role="group" aria-label="Billing"><button type="button" aria-pressed="false" data-v="m">Monthly</button><button type="button" aria-pressed="true" data-v="y">Yearly</button></div></div>
<label class="pyx-f"><span>Card number</span><div class="pyx-in"><input class="pyx-cn" inputmode="numeric" placeholder="1234 1234 1234 1234" autocomplete="off"><span class="pyx-br"></span></div></label>
<div class="pyx-2"><label class="pyx-f"><span>Expiry</span><div class="pyx-in"><input class="pyx-ex" inputmode="numeric" placeholder="MM / YY" autocomplete="off"></div></label><label class="pyx-f"><span>CVC</span><div class="pyx-in"><input class="pyx-cv" inputmode="numeric" placeholder="123" maxlength="4" autocomplete="off"></div></label></div>
<div class="pyx-sum"><div><span>3 editors × <span class="num pyx-u">$24</span></span><span class="num pyx-s">$72.00</span></div><div class="pyx-dc"><span>Yearly discount</span><span class="num pyx-d">−$14.40</span></div><div class="pyx-t"><b>Due today</b><b class="num pyx-tot">$691.20</b></div></div>
<button class="pill accent pyx-go" type="submit"><svg class="i xs"><use href="#i-lock"/></svg>Pay <span class="num pyx-gt">$691.20</span></button>
</form>
<span class="muted pyx-n pyx-out">Demo only. Nothing is charged.</span></div>
</div>
</div>
payment-form.cssHTML · 35 lines
/* ---------- Payment form ---------- */
.pyx{padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3);max-width:360px;margin:0 auto;width:100%}
.pyx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.pyx-h>div:first-child{display:flex;flex-direction:column;font-size:14px}
.pyx-h b{font-weight:500}
.pyx-h .muted{font-size:12px}
.pyx-per button{font-size:12px;height:26px;padding:0 9px}
/* the card's fields have no box: the text lines up with its label, with room above and below it, over a dimmed line that
steps up while the field has the focus. Left, a filled field looks like an empty one */
.pyx-f{display:flex;flex-direction:column;gap:6px;font-size:12.5px;font-weight:500;min-width:0}
/* each field is a full outline like every other field: a dimmed grey edge, empty or filled, one step stronger only while it
has the focus */
.pyx-in{display:flex;align-items:center;gap:var(--s-2);height:var(--field-h);padding:0 var(--s-2) 0 var(--s-3);border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.pyx-in{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.pyx-in:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
/* a field marked wrong keeps its red line, focused or not, until it is fixed */
.pyx-f.bad .pyx-in{box-shadow:inset 0 0 0 1px var(--bad)}
.pyx-in input{flex:1;min-width:0;height:100%;padding:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;font-weight:400;font-variant-numeric:tabular-nums;color:var(--fg)}
.pyx-in input::placeholder{color:var(--muted)}
.pyx-br{font-size:10px;font-weight:500;letter-spacing:.04em;padding:3px 6px;border-radius:var(--r-xs);opacity:0;transform:scale(.8);transition:opacity .3s,transform .35s var(--ease)}
.pyx-br.on{opacity:1;transform:none}
.pyx-2{display:grid;grid-template-columns:1fr 1fr;gap:var(--s-3)}
/* the details: plain rows after a line, no panel */
.pyx-sum{display:flex;flex-direction:column;gap:6px;margin-top:var(--s-1);padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:12.5px;color:var(--fg2)}
.pyx-sum>div{display:flex;justify-content:space-between}
.pyx-dc{color:var(--ok);transition:opacity .3s}
.pyx-dc.off{opacity:.3;text-decoration:line-through}
.pyx-t{padding-top:6px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);color:var(--fg);font-size:13.5px}
.pyx-t b{font-weight:500}
.pyx-go{height:var(--field-h);width:100%}
.pyx-go.ok{background:var(--ok)}
.pyx-n{font-size:11.5px;text-align:center}
/* on the site the form stands alone, with the demo line under it, outside the form */
.pyx-wrap{display:flex;flex-direction:column;align-items:center;gap:var(--s-3);width:100%;max-width:360px;margin:0 auto}
payment-form.jsHTML · 23 lines
DM.def('payment-form', (r, c) => {
r.innerHTML = r.innerHTML.replace('{lock}', I('lock','xs'));
const cn = $('.pyx-cn', r), ex = $('.pyx-ex', r), cv = $('.pyx-cv', r), br = $('.pyx-br', r);
const BR = [[/^4/, 'VISA', '#1a1f71'], [/^5[1-5]/, 'MC', '#eb001b'], [/^3[47]/, 'AMEX', '#2e77bb']];
cn.oninput = demo => { const d = cn.value.replace(/\D/g, '').slice(0, 16); cn.value = d.replace(/(.{4})/g, '$1 ').trim(); const b = BR.find(x => x[0].test(d));
br.classList.toggle('on', !!b); if (b){ br.textContent = b[1]; br.style.background = b[2]; br.style.color = '#fff'; } cn.closest('.pyx-f').classList.remove('bad'); if (d.length === 16 && demo !== true) ex.focus({ preventScroll:true }); };
ex.oninput = () => { let d = ex.value.replace(/\D/g, '').slice(0, 4); if (d.length === 1 && +d > 1) d = '0' + d; ex.value = d.length > 2 ? d.slice(0, 2) + ' / ' + d.slice(2) : d; ex.closest('.pyx-f').classList.remove('bad'); if (d.length === 4) cv.focus({ preventScroll:true }); };
cv.oninput = () => { cv.value = cv.value.replace(/\D/g, ''); cv.closest('.pyx-f').classList.remove('bad'); };
let per = 'y', tot = 691.2;
function price(){ const unit = 24, sub = unit * 3, yr = per === 'y'; const t = yr ? sub * 12 * .8 : sub;
$('.pyx-s', r).textContent = '$' + (yr ? sub * 12 : sub).toFixed(2); $('.pyx-d', r).textContent = yr ? '−$' + (sub * 12 * .2).toFixed(2) : '−$0.00'; $('.pyx-dc', r).classList.toggle('off', !yr);
$('.pyx-u', r).textContent = yr ? '$24 × 12' : '$24';
tween(tot, t, 600, v => { tot = v; $('.pyx-tot', r).textContent = '$' + v.toFixed(2); $('.pyx-gt', r).textContent = '$' + v.toFixed(2); }); }
seg($('.pyx-per', r), v => { per = v; price(); });
price();
$('.pyx', r).onsubmit = e => { e.preventDefault(); let ok = true;
[[cn, cn.value.replace(/\D/g, '').length === 16], [ex, /^\d\d \/ \d\d$/.test(ex.value)], [cv, cv.value.length >= 3]].forEach(([el, good]) => { if (!good){ ok = false; const f = el.closest('.pyx-f'); f.classList.remove('bad'); void f.offsetWidth; f.classList.add('bad'); } });
if (!ok) return;
c.run(async () => { const go = $('.pyx-go', r); go.innerHTML = '<span class="spin" style="border-color:rgba(255,255,255,.3);border-top-color:#fff"></span>Processing'; await c.wait(1200); go.classList.add('ok'); go.innerHTML = I('check','xs') + 'Payment successful'; enter(go.firstChild); }); };
// the preview types a test card on its own; clicking, focusing or typing in it takes over, and what it had typed goes
c.auto(1000, () => c.run(() => c.type(cn, '4242424242424242', { ms:45, each:() => cn.oninput(true) })));
});
Questions
What people ask about it.
- Does this component charge the card?
- No. It formats and checks the fields, then calls
onPaywith the card, period and total. Sending the payment to your provider happens in that handler. - How is the yearly price worked out?
- Yearly is
pricetimesseatstimes 12, minusyearlyDiscount, which is 0.2 by default. Monthly ispricetimesseatswith no discount. - Can I show prices in euros?
- Set
currencyto "€". It is placed before every amount, and amounts show two decimals.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.