Payment method
Card entry with live formatting and a real Luhn checksum.
Live preview
Source
This exact file renders the preview above.
'use client'
import { CreditCard, Lock } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Payment method
*
* A card form that formats as you type and validates with the Luhn checksum —
* the same check a real gateway runs first, and the one that catches a mistyped
* digit before a network round trip.
*
* Nothing is transmitted. Use the standard test number 4242 4242 4242 4242.
*/
function luhnValid(digits: string): boolean {
if (digits.length < 13) return false
let sum = 0
let double = false
for (let i = digits.length - 1; i >= 0; i -= 1) {
let value = Number(digits[i])
if (double) {
value *= 2
if (value > 9) value -= 9
}
sum += value
double = !double
}
return sum % 10 === 0
}
function formatCard(value: string): string {
return value
.replace(/\D/g, '')
.slice(0, 19)
.replace(/(\d{4})(?=\d)/g, '$1 ')
.trim()
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 4)
if (digits.length <= 2) return digits
return `${digits.slice(0, 2)}/${digits.slice(2)}`
}
export default function PaymentMethodForm() {
const form = useDemoForm({
schema: {
cardholder: { validators: [required('Cardholder name')] },
number: {
validators: [
required('Card number'),
(value) =>
luhnValid(value.replace(/\s/g, '')) ? null : 'That card number is not valid.',
],
},
expiry: {
validators: [
required('Expiry'),
pattern(/^(0[1-9]|1[0-2])\/\d{2}$/, 'Use MM/YY, for example 04/29.'),
],
},
cvc: {
validators: [
required('Security code'),
pattern(/^\d{3,4}$/, 'Enter the 3 or 4 digit code.'),
],
},
},
})
return (
<FormShell
title="Payment method"
description="Test card: 4242 4242 4242 4242, any future expiry, any CVC."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save card"
submittingLabel="Saving card"
onReset={form.reset}
successTitle="Card saved"
successBody={<p>This is a template demo. No submission was sent and no card was stored.</p>}
>
<Alert tone="warning" title="Never enter a real card">
This form has no payment provider behind it. It exists to demonstrate formatting and
validation only.
</Alert>
<Field name="cardholder" label="Name on card" required error={form.error('cardholder')}>
{(field) => <Input {...field} autoComplete="cc-name" {...form.field('cardholder')} />}
</Field>
<Field name="number" label="Card number" required error={form.error('number')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-number"
className="font-mono"
placeholder="4242 4242 4242 4242"
leading={<CreditCard className="size-4" />}
value={form.values.number}
onChange={(event) => form.setValue('number', formatCard(event.target.value))}
/>
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="expiry" label="Expiry" required error={form.error('expiry')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-exp"
className="font-mono"
placeholder="MM/YY"
value={form.values.expiry}
onChange={(event) => form.setValue('expiry', formatExpiry(event.target.value))}
/>
)}
</Field>
<Field
name="cvc"
label="Security code"
required
error={form.error('cvc')}
hint="Three digits on the back, four on the front for Amex."
>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-csc"
className="font-mono"
trailing={<Lock className="size-3.5" />}
{...form.field('cvc')}
/>
)}
</Field>
</div>
</FormShell>
)
}
components/blocks/forms/payment-method.tsx
'use client'
import { CreditCard, Lock } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Payment method
*
* A card form that formats as you type and validates with the Luhn checksum —
* the same check a real gateway runs first, and the one that catches a mistyped
* digit before a network round trip.
*
* Nothing is transmitted. Use the standard test number 4242 4242 4242 4242.
*/
function luhnValid(digits: string): boolean {
if (digits.length < 13) return false
let sum = 0
let double = false
for (let i = digits.length - 1; i >= 0; i -= 1) {
let value = Number(digits[i])
if (double) {
value *= 2
if (value > 9) value -= 9
}
sum += value
double = !double
}
return sum % 10 === 0
}
function formatCard(value: string): string {
return value
.replace(/\D/g, '')
.slice(0, 19)
.replace(/(\d{4})(?=\d)/g, '$1 ')
.trim()
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 4)
if (digits.length <= 2) return digits
return `${digits.slice(0, 2)}/${digits.slice(2)}`
}
export default function PaymentMethodForm() {
const form = useDemoForm({
schema: {
cardholder: { validators: [required('Cardholder name')] },
number: {
validators: [
required('Card number'),
(value) =>
luhnValid(value.replace(/\s/g, '')) ? null : 'That card number is not valid.',
],
},
expiry: {
validators: [
required('Expiry'),
pattern(/^(0[1-9]|1[0-2])\/\d{2}$/, 'Use MM/YY, for example 04/29.'),
],
},
cvc: {
validators: [
required('Security code'),
pattern(/^\d{3,4}$/, 'Enter the 3 or 4 digit code.'),
],
},
},
})
return (
<FormShell
title="Payment method"
description="Test card: 4242 4242 4242 4242, any future expiry, any CVC."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save card"
submittingLabel="Saving card"
onReset={form.reset}
successTitle="Card saved"
successBody={<p>This is a template demo. No submission was sent and no card was stored.</p>}
>
<Alert tone="warning" title="Never enter a real card">
This form has no payment provider behind it. It exists to demonstrate formatting and
validation only.
</Alert>
<Field name="cardholder" label="Name on card" required error={form.error('cardholder')}>
{(field) => <Input {...field} autoComplete="cc-name" {...form.field('cardholder')} />}
</Field>
<Field name="number" label="Card number" required error={form.error('number')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-number"
className="font-mono"
placeholder="4242 4242 4242 4242"
leading={<CreditCard className="size-4" />}
value={form.values.number}
onChange={(event) => form.setValue('number', formatCard(event.target.value))}
/>
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="expiry" label="Expiry" required error={form.error('expiry')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-exp"
className="font-mono"
placeholder="MM/YY"
value={form.values.expiry}
onChange={(event) => form.setValue('expiry', formatExpiry(event.target.value))}
/>
)}
</Field>
<Field
name="cvc"
label="Security code"
required
error={form.error('cvc')}
hint="Three digits on the back, four on the front for Amex."
>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-csc"
className="font-mono"
trailing={<Lock className="size-3.5" />}
{...form.field('cvc')}
/>
)}
</Field>
</div>
</FormShell>
)
}
lib/validation.ts
/**
* Validation rules.
*
* Small, composable predicates returning either an error string or `null`.
* Messages are written to be actionable — "Enter your work email" rather than
* "Invalid" — because an error message is the only part of a form a user reads
* carefully.
*/
export type Validator = (value: string, values: Record<string, string>) => string | null
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
const URL_LIKE = /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/\S*)?$/
const PHONE = /^[+]?[\d\s()-]{7,20}$/
export const required =
(label = 'This field'): Validator =>
(value) =>
value.trim().length === 0 ? `${label} is required.` : null
export const email =
(message = 'Enter a valid email address, for example name@company.com.'): Validator =>
(value) =>
value.trim().length === 0 || EMAIL.test(value.trim()) ? null : message
export const minLength =
(length: number, label = 'This field'): Validator =>
(value) =>
value.trim().length === 0 || value.trim().length >= length
? null
: `${label} must be at least ${length} characters.`
export const maxLength =
(length: number, label = 'This field'): Validator =>
(value) =>
value.length <= length ? null : `${label} must be ${length} characters or fewer.`
export const url =
(message = 'Enter a valid URL, for example example.com.'): Validator =>
(value) =>
value.trim().length === 0 || URL_LIKE.test(value.trim()) ? null : message
export const phone =
(message = 'Enter a valid phone number.'): Validator =>
(value) =>
value.trim().length === 0 || PHONE.test(value.trim()) ? null : message
export const pattern =
(regex: RegExp, message: string): Validator =>
(value) =>
value.trim().length === 0 || regex.test(value.trim()) ? null : message
export const matches =
(otherField: string, message: string): Validator =>
(value, values) =>
value === (values[otherField] ?? '') ? null : message
export const numeric =
(message = 'Enter a number.'): Validator =>
(value) =>
value.trim().length === 0 || /^-?\d+(\.\d+)?$/.test(value.trim()) ? null : message
export const checked =
(message = 'This must be accepted to continue.'): Validator =>
(value) =>
value === 'true' ? null : message
/** Password strength used across the authentication forms. */
export const strongPassword =
(message = 'Use at least 10 characters, including a number and a letter.'): Validator =>
(value) => {
if (value.length === 0) return null
const longEnough = value.length >= 10
const hasLetter = /[a-zA-Z]/.test(value)
const hasNumber = /\d/.test(value)
return longEnough && hasLetter && hasNumber ? null : message
}
export function passwordScore(value: string): { score: 0 | 1 | 2 | 3 | 4; label: string } {
if (!value) return { score: 0, label: 'Empty' }
let score = 0
if (value.length >= 10) score += 1
if (value.length >= 14) score += 1
if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score += 1
if (/\d/.test(value) && /[^\w\s]/.test(value)) score += 1
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'] as const
const clamped = Math.min(4, score) as 0 | 1 | 2 | 3 | 4
return { score: clamped, label: labels[clamped] }
}
export function runValidators(
value: string,
values: Record<string, string>,
validators: Validator[],
): string | null {
for (const validate of validators) {
const error = validate(value, values)
if (error) return error
}
return null
}
components/ui/alert.tsx
import type { ReactNode } from 'react'
import { Info, CheckCircle2, AlertTriangle, OctagonAlert } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Alert
*
* A block-level message attached to a region of the page. The icon is chosen
* from the tone, and `role` switches to `alert` for danger so screen readers
* interrupt — informational tones use the polite `status` role instead.
*/
export type AlertTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
export interface AlertProps {
tone?: AlertTone
title?: ReactNode
children?: ReactNode
/** Rendered under the body — usually one or two buttons. */
actions?: ReactNode
/** Slot for a dismiss control supplied by the caller. */
trailing?: ReactNode
className?: string
/** Force the live-region politeness rather than deriving it from tone. */
live?: 'off' | 'polite' | 'assertive'
}
const tones = {
info: { icon: Info, surface: 'bg-info-soft border-info-line', accent: 'text-info' },
success: {
icon: CheckCircle2,
surface: 'bg-success-soft border-success-line',
accent: 'text-success',
},
warning: {
icon: AlertTriangle,
surface: 'bg-warning-soft border-warning-line',
accent: 'text-warning',
},
danger: {
icon: OctagonAlert,
surface: 'bg-danger-soft border-danger-line',
accent: 'text-danger',
},
neutral: { icon: Info, surface: 'bg-surface-sunken border-line', accent: 'text-ink-muted' },
} as const
export function Alert({
tone = 'info',
title,
children,
actions,
trailing,
className,
live,
}: AlertProps) {
const entry = tones[tone]
const Icon = entry.icon
const politeness = live ?? (tone === 'danger' ? 'assertive' : 'polite')
return (
<div
role={tone === 'danger' ? 'alert' : 'status'}
aria-live={politeness === 'off' ? undefined : politeness}
className={cn('flex gap-3 rounded-md border p-3 text-sm', entry.surface, className)}
>
<Icon className={cn('mt-0.5 size-4 shrink-0', entry.accent)} aria-hidden="true" />
<div className="min-w-0 flex-1">
{title ? <p className="font-semibold text-ink-strong">{title}</p> : null}
{children ? <div className={cn('text-ink-muted', title && 'mt-1')}>{children}</div> : null}
{actions ? <div className="mt-3 flex flex-wrap gap-2">{actions}</div> : null}
</div>
{trailing ? <div className="shrink-0">{trailing}</div> : null}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Validates with the Luhn checksum — the same check a real gateway runs first, and the one that catches a mistyped digit before a network round trip. Nothing is transmitted.
- Formatting happens on change, so the value in state stays the display value and the two cannot diverge.
- A prominent warning states there is no payment provider behind the form.
- Card autocomplete tokens let browsers fill saved cards.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Live number formatting
- Expiry masking
- Luhn validation
- Explicit demo warning
Accessibility
- Numeric input mode
- `inputMode=“numeric”` gives a numeric keypad without rejecting pasted spaces.
- Checksum errors
- The failure says the number is invalid, not that a field is “required”.
- Explicit warning
- The demo caveat is an Alert, announced politely, not fine print.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsCheckout wizard
Four steps with the order summary repeated on every one.
advancedFeatured5 variantsBilling details
Conditional VAT fields for business accounts, with cross-field required rules.
intermediate4 variantsPromotion code
The smallest form in the library, with a distinct message for each way a code can fail.
starter4 variants