A card entry field whose network glyph blur-swaps in as digits identify the brand, with brand-aware grouping plus expiry and CVC fields. Never stores or logs values.
npx shadcn@latest add @paragon/card-number-input"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CreditCard } from "lucide-react";
import { cn } from "@/lib/utils";
type Brand = "visa" | "mastercard" | "amex" | "discover" | "generic";
interface BrandSpec {
/** How the number is grouped, e.g. [4,4,4,4]. */
gaps: number[];
/** Total digit count (max) for this brand. */
length: number;
/** CVC digit count. */
cvc: number;
}
const BRAND_SPECS: Record<Brand, BrandSpec> = {
visa: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
mastercard: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
discover: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
amex: { gaps: [4, 6, 5], length: 15, cvc: 4 },
generic: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
};
/** Identifies the network from the leading digits. */
function detectBrand(digits: string): Brand {
if (/^4/.test(digits)) return "visa";
if (/^(34|37)/.test(digits)) return "amex";
if (/^(5[1-5]|2[2-7])/.test(digits)) return "mastercard";
if (/^(6011|65|64[4-9])/.test(digits)) return "discover";
return "generic";
}
function groupDigits(digits: string, gaps: number[]) {
const out: string[] = [];
let i = 0;
for (const gap of gaps) {
if (i >= digits.length) break;
out.push(digits.slice(i, i + gap));
i += gap;
}
if (i < digits.length) out.push(digits.slice(i));
return out.join(" ");
}
function BrandGlyph({ brand }: { brand: Brand }) {
const common = "block h-full w-full";
switch (brand) {
case "visa":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#1434CB" />
<path
d="M17.2 16.4 18.7 7.6h2.4l-1.5 8.8h-2.4Zm11-8.6c-.5-.2-1.2-.4-2.1-.4-2.3 0-4 1.2-4 3 0 1.3 1.2 2 2.1 2.4.9.4 1.2.7 1.2 1.1 0 .6-.7.8-1.4.8-.9 0-1.4-.1-2.2-.5l-.3-.1-.3 1.9c.6.2 1.6.5 2.6.5 2.5 0 4.1-1.2 4.1-3.1 0-1-.6-1.8-2-2.4-.8-.4-1.3-.7-1.3-1.1 0-.4.4-.7 1.3-.7.7 0 1.3.1 1.7.3l.2.1.3-1.8Zm5.9-.2h-1.9c-.6 0-1 .2-1.3.8l-3.6 8.2h2.5s.4-1.1.5-1.4h3.1c.1.3.3 1.4.3 1.4h2.2l-1.8-9Zm-3 5.9c.2-.5 1-2.5 1-2.5s.2-.5.3-.9l.2.8s.5 2.2.6 2.6h-2.1Zm-12.9-5.9-2.4 6-.3-1.3c-.4-1.5-1.9-3.2-3.5-4l2.2 8.2h2.5l3.8-8.9h-2.3Z"
fill="#fff"
/>
<path
d="M12.6 7.6H8.8l-.1.3c3 .7 5 2.5 5.8 4.6l-.8-4.1c-.1-.6-.5-.8-1.1-.8Z"
fill="#F7B600"
/>
</svg>
);
case "mastercard":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#252525" />
<circle cx="16" cy="12" r="7" fill="#EB001B" />
<circle cx="24" cy="12" r="7" fill="#F79E1B" />
<path
d="M20 6.6a7 7 0 0 1 0 10.8 7 7 0 0 1 0-10.8Z"
fill="#FF5F00"
/>
</svg>
);
case "amex":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#1F72CD" />
<text
x="20"
y="15"
textAnchor="middle"
fontSize="7"
fontWeight="700"
fill="#fff"
fontFamily="Arial, sans-serif"
>
AMEX
</text>
</svg>
);
case "discover":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#1a1a1a" />
<path d="M40 24H16c8-2 18-7 24-14v14Z" fill="#F76B1C" />
<circle cx="28" cy="12" r="4" fill="#F76B1C" />
</svg>
);
default:
return (
<CreditCard className="size-5 text-muted-foreground" aria-hidden />
);
}
}
export interface CardNumberInputProps {
className?: string;
/** Fires as fields change. Digits only for number/cvc; MM/YY for expiry. */
onValueChange?: (value: {
number: string;
expiry: string;
cvc: string;
brand: Brand;
complete: boolean;
}) => void;
disabled?: boolean;
}
/**
* A card entry field where the network glyph blur-swaps in as soon as the
* leading digits identify the brand (Visa, Mastercard, Amex, Discover). The
* grouping mask adapts to the brand (Amex is 4-6-5, CVC becomes 4 digits).
* Expiry and CVC share the row. Nothing is persisted or logged — values live
* only in local state and are surfaced through `onValueChange`.
*/
export function CardNumberInput({
className,
onValueChange,
disabled,
}: CardNumberInputProps) {
const reduced = useReducedMotion();
const baseId = React.useId();
const [number, setNumber] = React.useState("");
const [expiry, setExpiry] = React.useState("");
const [cvc, setCvc] = React.useState("");
const brand = detectBrand(number);
const spec = BRAND_SPECS[brand];
const complete =
number.length === spec.length &&
/^\d{2}\/\d{2}$/.test(expiry) &&
cvc.length === spec.cvc;
// Report changes without making onValueChange a render-time dependency.
const changeRef = React.useRef(onValueChange);
changeRef.current = onValueChange;
React.useEffect(() => {
changeRef.current?.({ number, expiry, cvc, brand, complete });
}, [number, expiry, cvc, brand, complete]);
function handleNumber(e: React.ChangeEvent<HTMLInputElement>) {
const digits = e.target.value.replace(/\D/g, "").slice(0, spec.length);
setNumber(digits);
// If the CVC no longer fits the brand's width, trim it.
setCvc((prev) => prev.slice(0, BRAND_SPECS[detectBrand(digits)].cvc));
}
function handleExpiry(e: React.ChangeEvent<HTMLInputElement>) {
let digits = e.target.value.replace(/\D/g, "").slice(0, 4);
if (digits.length >= 3) digits = `${digits.slice(0, 2)}/${digits.slice(2)}`;
else if (digits.length === 2 && !expiry.endsWith("/")) digits = `${digits}/`;
setExpiry(digits);
}
function handleCvc(e: React.ChangeEvent<HTMLInputElement>) {
setCvc(e.target.value.replace(/\D/g, "").slice(0, spec.cvc));
}
return (
<div className={cn("w-full max-w-sm", className)}>
<label
htmlFor={`${baseId}-number`}
className="mb-1.5 block text-sm font-medium text-foreground"
>
Card number
</label>
<div
className={cn(
"relative flex h-9 items-center rounded-lg border border-input bg-transparent pl-3 pr-2 text-sm",
"transition-[border-color,box-shadow] duration-150 ease-out",
"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
disabled && "cursor-not-allowed opacity-50",
)}
>
<input
id={`${baseId}-number`}
inputMode="numeric"
autoComplete="cc-number"
disabled={disabled}
placeholder="1234 1234 1234 1234"
value={groupDigits(number, spec.gaps)}
onChange={handleNumber}
aria-label="Card number"
className="w-full min-w-0 flex-1 bg-transparent tabular-nums outline-none placeholder:text-muted-foreground"
/>
<span className="ml-2 flex h-6 w-9 shrink-0 items-center justify-center overflow-hidden rounded">
{reduced ? (
<BrandGlyph brand={brand} />
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={brand}
className="block h-6 w-9"
initial={{ opacity: 0, scale: 0.6, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.6, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<BrandGlyph brand={brand} />
</motion.span>
</AnimatePresence>
)}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label
htmlFor={`${baseId}-expiry`}
className="mb-1.5 block text-sm font-medium text-foreground"
>
Expiry
</label>
<input
id={`${baseId}-expiry`}
inputMode="numeric"
autoComplete="cc-exp"
disabled={disabled}
placeholder="MM/YY"
value={expiry}
onChange={handleExpiry}
aria-label="Expiry date"
className={cn(
"h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm tabular-nums outline-none",
"transition-[border-color,box-shadow] duration-150 ease-out",
"placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
</div>
<div>
<label
htmlFor={`${baseId}-cvc`}
className="mb-1.5 block text-sm font-medium text-foreground"
>
CVC
</label>
<input
id={`${baseId}-cvc`}
inputMode="numeric"
autoComplete="cc-csc"
disabled={disabled}
placeholder={spec.cvc === 4 ? "1234" : "123"}
value={cvc}
onChange={handleCvc}
aria-label="Security code"
className={cn(
"h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm tabular-nums outline-none",
"transition-[border-color,box-shadow] duration-150 ease-out",
"placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
</div>
</div>
</div>
);
}