Skip to content
Star on GitHub
Forms and inputsPayment form
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Payment 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.

$npx designme add payment-form
Pro planFor growing design teams
3 editors × $24$72.00
Yearly discount−$14.40
Due today$691.20
Demo only. Nothing is charged.
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add payment-form

React

$npx designme add payment-form --react

The 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.

02

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
03

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>
  );
}
04

Props

Everything PaymentForm takes.

PropTypeWhat it does
planReactNodeDefaults to "Pro plan"
descriptionReactNodeDefaults to "For growing design teams"
seatsnumberDefaults to 3
seatLabelstringDefaults to "editors"
pricenumberPrice per seat per month. Defaults to 24
currencystringPut before every amount. Defaults to "$"
yearlyDiscountnumberTaken off a yearly plan, 0 to 1. Defaults to 0.2
periodPaymentPeriodMonthly or yearly. Leave it out to let the switch hold its own
defaultPeriodPaymentPeriod
onPeriodChange(period: PaymentPeriod) => void
cardPaymentCardThe card fields. Leave it out to let the fields hold their own
defaultCardPaymentCard
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
payLabelstring
processingLabelstring
paidLabelstring
discountLabelstring
dueLabelstring
noteReactNodeA quiet line under the button, like "Nothing is charged until your trial ends"
errorLabelstringShown when onPay rejects without a message of its own
classNamestring
05

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) })));
});
06

Questions

What people ask about it.

Does this component charge the card?
No. It formats and checks the fields, then calls onPay with the card, period and total. Sending the payment to your provider happens in that handler.
How is the yearly price worked out?
Yearly is price times seats times 12, minus yearlyDiscount, which is 0.2 by default. Monthly is price times seats with no discount.
Can I show prices in euros?
Set currency to "€". It is placed before every amount, and amounts show two decimals.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
All 11 in Forms and inputs →