Card Payment Input
Credit card fields with live number formatting, brand detection, expiry auto-slash, and CVC length per brand.
Installation
npx cano-ui add card-payment-input
Installs input, label, lucide-react automatically if missing.
Source
"use client"
import * as React from "react"
import { CreditCard, Lock } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export type CardBrand = "visa" | "mastercard" | "amex" | "discover" | null
export interface CardPaymentValue {
number: string
expiry: string
cvc: string
brand: CardBrand
/** True when all three fields are plausibly complete. */
complete: boolean
}
export interface CardPaymentInputProps {
onChange?: (value: CardPaymentValue) => void
disabled?: boolean
className?: string
}
function detectBrand(digits: string): CardBrand {
if (/^4/.test(digits)) return "visa"
if (/^(5[1-5]|2[2-7])/.test(digits)) return "mastercard"
if (/^3[47]/.test(digits)) return "amex"
if (/^6(011|5)/.test(digits)) return "discover"
return null
}
const brandLabels: Record<Exclude<CardBrand, null>, string> = {
visa: "Visa",
mastercard: "Mastercard",
amex: "Amex",
discover: "Discover",
}
function formatCardNumber(digits: string, brand: CardBrand) {
// Amex groups 4-6-5; everything else 4-4-4-4.
const groups = brand === "amex" ? [4, 6, 5] : [4, 4, 4, 4]
const parts: string[] = []
let rest = digits
for (const size of groups) {
if (!rest) break
parts.push(rest.slice(0, size))
rest = rest.slice(size)
}
return parts.join(" ")
}
export function CardPaymentInput({
onChange,
disabled,
className,
}: CardPaymentInputProps) {
const id = React.useId()
const [number, setNumber] = React.useState("")
const [expiry, setExpiry] = React.useState("")
const [cvc, setCvc] = React.useState("")
const digits = number.replace(/\D/g, "")
const brand = detectBrand(digits)
const numberLength = brand === "amex" ? 15 : 16
const cvcLength = brand === "amex" ? 4 : 3
function emit(next: { number?: string; expiry?: string; cvc?: string }) {
const n = (next.number ?? number).replace(/\D/g, "")
const e = next.expiry ?? expiry
const c = next.cvc ?? cvc
const b = detectBrand(n)
onChange?.({
number: n,
expiry: e,
cvc: c,
brand: b,
complete:
n.length === (b === "amex" ? 15 : 16) &&
/^(0[1-9]|1[0-2])\/\d{2}$/.test(e) &&
c.length === (b === "amex" ? 4 : 3),
})
}
return (
<div
data-slot="card-payment-input"
className={cn("flex w-full flex-col gap-4", className)}
>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-number`}>Card number</Label>
<div className="relative">
<CreditCard
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id={`${id}-number`}
inputMode="numeric"
autoComplete="cc-number"
placeholder="1234 5678 9012 3456"
value={number}
disabled={disabled}
onChange={(e) => {
const d = e.target.value.replace(/\D/g, "").slice(0, numberLength)
const formatted = formatCardNumber(d, detectBrand(d))
setNumber(formatted)
emit({ number: formatted })
}}
className="pl-9 pr-20 font-mono tabular-nums"
/>
{brand ? (
<span className="absolute top-1/2 right-3 -translate-y-1/2 rounded-sm border bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{brandLabels[brand]}
</span>
) : null}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-expiry`}>Expiry</Label>
<Input
id={`${id}-expiry`}
inputMode="numeric"
autoComplete="cc-exp"
placeholder="MM/YY"
value={expiry}
disabled={disabled}
onChange={(e) => {
let d = e.target.value.replace(/\D/g, "").slice(0, 4)
if (d.length === 1 && Number(d) > 1) d = `0${d}`
const next = d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d
setExpiry(next)
emit({ expiry: next })
}}
className="font-mono tabular-nums"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-cvc`}>CVC</Label>
<div className="relative">
<Input
id={`${id}-cvc`}
type="password"
inputMode="numeric"
autoComplete="cc-csc"
placeholder={"•".repeat(cvcLength)}
value={cvc}
disabled={disabled}
onChange={(e) => {
const next = e.target.value.replace(/\D/g, "").slice(0, cvcLength)
setCvc(next)
emit({ cvc: next })
}}
className="pr-9 font-mono"
/>
<Lock
aria-hidden="true"
className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
</div>
</div>
</div>
</div>
)
}
This exact file lands in your project at components/ui/card-payment-input.tsx. It is yours to edit.