A free credit card input on shadcn's Input
components/card-number-input.tsx
"use client";
import { useLayoutEffect, useRef, useState, type ChangeEvent, type ComponentProps } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cardNumberValid, cardRule, formatCardNumber, onlyDigits, type CardBrand } from "@/lib/card";
const BRAND_LABEL: Record<CardBrand, string> = { visa: "Visa", mastercard: "Mastercard", amex: "Amex" };
// Where the caret goes after reformatting: right after the same number of digits as before.
function caretAfter(formatted: string, digitsBefore: number) {
let seen = 0;
for (let i = 0; i < formatted.length; i++) {
if (seen === digitsBefore) return i;
if (/\d/.test(formatted[i])) seen++;
}
return formatted.length;
}
type Props = Omit<ComponentProps<typeof Input>, "value" | "onChange" | "type"> & {
/** digits only, the detected brand, and whether the number is complete and passes Luhn */
onValueChange?: (card: { digits: string; brand: CardBrand | null; valid: boolean }) => void;
};
export function CardNumberInput({ onValueChange, className, ...props }: Props) {
const ref = useRef<HTMLInputElement>(null);
const caret = useRef<number | null>(null);
const [value, setValue] = useState("");
const digits = onlyDigits(value);
const { brand, lengths } = cardRule(digits);
const full = lengths.includes(digits.length);
const invalid = full && !cardNumberValid(digits);
useLayoutEffect(() => {
if (caret.current !== null && ref.current === document.activeElement) {
ref.current?.setSelectionRange(caret.current, caret.current);
}
caret.current = null;
}, [value]);
function onChange(e: ChangeEvent<HTMLInputElement>) {
const el = e.target;
let raw = el.value;
let pos = el.selectionStart ?? raw.length;
// Backspace or Delete on a space removes only the space, and formatting would put it straight back.
// Remove the digit next to it instead, the way a plain text field would feel.
const kind = (e.nativeEvent as InputEvent).inputType;
if (raw.length < value.length && onlyDigits(raw) === onlyDigits(value)) {
if (kind === "deleteContentBackward" && pos > 0) {
raw = raw.slice(0, pos - 1) + raw.slice(pos);
pos -= 1;
} else if (kind === "deleteContentForward") {
raw = raw.slice(0, pos) + raw.slice(pos + 1);
}
}
const before = onlyDigits(raw.slice(0, pos)).length;
const next = formatCardNumber(raw);
caret.current = caretAfter(next, before);
setValue(next);
const d = onlyDigits(next);
onValueChange?.({ digits: d, brand: cardRule(d).brand, valid: cardNumberValid(d) });
}
return (
<div className="relative">
<Input
{...props}
ref={ref}
type="text"
inputMode="numeric"
autoComplete="cc-number"
placeholder={props.placeholder ?? "1234 1234 1234 1234"}
value={value}
onChange={onChange}
aria-invalid={invalid || undefined}
className={`pr-28 font-mono tabular-nums ${className ?? ""}`}
/>
{brand && (
<Badge variant="secondary" className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2">
{BRAND_LABEL[brand]}
</Badge>
)}
</div>
);
}
lib/card.ts
// Card number helpers for your own inputs: grouping, brand, length, Luhn and expiry.
export type CardBrand = "visa" | "mastercard" | "amex";
type Rule = { brand: CardBrand; test: RegExp; gaps: number[]; lengths: number[]; cvc: number };
// The usual prefixes for each brand. Unknown numbers still format in fours.
const RULES: Rule[] = [
{ brand: "amex", test: /^3[47]/, gaps: [4, 10], lengths: [15], cvc: 4 },
{ brand: "visa", test: /^4/, gaps: [4, 8, 12, 16], lengths: [13, 16, 19], cvc: 3 },
{ brand: "mastercard", test: /^(5[1-5]|222[1-9]|22[3-9]\d|2[3-6]\d\d|27[01]\d|2720)/, gaps: [4, 8, 12], lengths: [16], cvc: 3 },
];
const FALLBACK = { gaps: [4, 8, 12, 16], lengths: [16, 19], cvc: 3 };
export const onlyDigits = (s: string) => s.replace(/\D/g, "");
export function cardRule(digits: string) {
const rule = RULES.find((r) => r.test.test(digits));
return { brand: rule?.brand ?? null, gaps: rule?.gaps ?? FALLBACK.gaps, lengths: rule?.lengths ?? FALLBACK.lengths, cvc: rule?.cvc ?? FALLBACK.cvc };
}
/** "4242424242424242" -> "4242 4242 4242 4242", "378282246310005" -> "3782 822463 10005" */
export function formatCardNumber(raw: string) {
const { gaps, lengths } = cardRule(onlyDigits(raw));
const digits = onlyDigits(raw).slice(0, Math.max(...lengths));
let out = "";
for (let i = 0; i < digits.length; i++) {
if (gaps.includes(i)) out += " ";
out += digits[i];
}
return out;
}
export function luhn(digits: string) {
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let n = Number(digits[digits.length - 1 - i]);
if (i % 2) n = n * 2 > 9 ? n * 2 - 9 : n * 2;
sum += n;
}
return digits.length > 0 && sum % 10 === 0;
}
/** Complete length for its brand and passes Luhn. It says nothing about whether the card exists. */
export function cardNumberValid(raw: string) {
const digits = onlyDigits(raw);
return cardRule(digits).lengths.includes(digits.length) && luhn(digits);
}
/** "1225" -> "12/25". The slash appears with the third digit, so Backspace can delete it. A first digit over 1 becomes "0X". */
export function formatExpiry(raw: string) {
let d = onlyDigits(raw).slice(0, 4);
if (/^[2-9]/.test(d)) d = "0" + d.slice(0, 3);
return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d;
}
/** "MM/YY", a real month, not in the past */
export function expiryValid(value: string, now = new Date()) {
const m = /^(0[1-9]|1[0-2])\/(\d{2})$/.exec(value);
if (!m) return false;
const year = 2000 + Number(m[2]);
const month = Number(m[1]);
return year > now.getFullYear() || (year === now.getFullYear() && month >= now.getMonth() + 1);
}