Commerce
Card field
Card number, expiry and security code as one grouped field that formats, detects the brand and checks it.
- Built on
autocomplete cc-number, cc-exp, cc-cscinputmodesetCustomValidityrole=groupContainer queries- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 5.8 kB
- Dependencies
- React
Charged on the 1st of each month.
Code
import { CardField } from "@/components/equip/card-field";
export default function Demo() {
return (
<div style={{ width: 224 }}>
<CardField label="Card details" hint="Charged on the 1st of each month." />
</div>
);
}Examples
Brand detection
The grouping, length and security code follow the network: American Express is 4-6-5 with a four-digit code.
Code
import { CardField } from "@/components/equip/card-field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 360 }}>
<CardField label="Visa test card" defaultValue={{ number: "4242424242424242", expiry: "0829", cvc: "123" }} />
<CardField label="American Express test card" defaultValue={{ number: "378282246310005", expiry: "1130", cvc: "1234" }} />
</div>
);
}In a checkout form
Each part reports its own problem through setCustomValidity, so the form will not submit until the number passes the Luhn check, the card has not expired and the code has the right length.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { CardField, type CardValue } from "@/components/equip/card-field";
export default function Example() {
const [card, setCard] = useState<CardValue | null>(null);
const [sent, setSent] = useState("");
return (
<form
noValidate
style={{ display: "grid", gap: 12, width: "100%", maxWidth: 360 }}
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
if (!form.checkValidity()) {
form.querySelector<HTMLInputElement>(":invalid")?.focus();
return;
}
setSent(`${card?.brand ?? "Card"} ending ${card?.number.slice(-4)} is ready to send to your payment provider.`);
}}
>
<CardField label="Card details" required onValueChange={setCard} />
<Button type="submit" variant="accent">
Pay €48.00
</Button>
<p role="status" style={{ margin: 0, minHeight: "1.4em", color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
{sent}
</p>
</form>
);
}Declined and disabled
Pass error for what only your processor knows, such as a decline. It replaces the field's own messages.
Your bank declined this card. Try another card or contact your bank.
Code
import { CardField } from "@/components/equip/card-field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 360 }}>
<CardField
label="Card details"
defaultValue={{ number: "4000000000000002", expiry: "0428", cvc: "321" }}
error="Your bank declined this card. Try another card or contact your bank."
/>
<CardField label="Saved card" defaultValue={{ number: "5555555555554444", expiry: "0230", cvc: "" }} disabled />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label for the group; names the role=group box. |
hint | ReactNode | none | Help text under the field, linked to all three inputs with aria-describedby. |
error | ReactNode | none | Your own error, such as a decline. Marks every part invalid and replaces the field's own messages. |
names | { number?: string; expiry?: string; cvc?: string } | { number: "cardnumber", expiry: "exp-date", cvc: "cvc" } | Form names of the three inputs. |
defaultValue | { number?: string; expiry?: string; cvc?: string } | none | Starting text of each part. Formatted on the way in. |
onValueChange | (value: CardValue) => void | none | Called after each change with number (digits), expiry (MM/YY), cvc, brand and complete. |
required | boolean | false | All three parts must be filled. Without it an empty field is valid and a partly filled one is not. |
disabled | boolean | false | Disables all three inputs. |
...rest | ComponentProps<"div"> | none | Every native div attribute on the wrapper, including ref. |
Anatomy
| root | .eq-card-field | Wrapper and size container; below 300px the box splits into two rows. |
|---|---|---|
| label | .eq-card-field-label | |
| control | .eq-card-field-control | The one bordered box, role=group, holding the three inputs. |
| input | .eq-card-field-input | Each of the three native inputs; data-part is number, expiry or cvc. |
| brand | .eq-card-field-brand | The detected network as text, beside the label and read with the number. |
| hint | .eq-card-field-hint | |
| error | .eq-card-field-error | The first problem among the parts you have left, or your error. |
Keyboard
| 0-9 | Types digits; spaces and the expiry slash are added for you and the caret stays after the digit typed. |
|---|---|
| TabShift+Tab | Moves between number, expiry and code. |
| Backspace | At the start of an empty expiry or code, moves back to the end of the part before. |
Notes
- It only formats and validates. It is not a payment integration and never sends card data anywhere.
- In production, collect card data with your payment processor's hosted fields or SDK so it never touches your servers, which keeps you out of PCI DSS scope; use this for prototypes, internal tools or processors that tokenise from your own inputs.
- Brand detection reads the number's prefix and shows the network as text: Visa, Mastercard, American Express, Discover, Diners Club, JCB, UnionPay or Maestro.
- Grouping, length and code length follow the brand: American Express is 4-6-5 with a four-digit code, others 4-4-4-4 with three.
- The number must pass the Luhn checksum, the expiry must be this month or later, and the code must have the right length; each problem is set with setCustomValidity on its own input, so a form will not submit.
- Messages appear once a part has been left or a submit has failed, never while typing; the first one shows under the field and is linked to every input.
- The cursor jumps on after a valid number of the usual length and after a full expiry, but waits on 20 in case a four-digit year follows.
- The expiry accepts MM / YY and MM / YYYY, and a first digit from 2 to 9 is read as a month, so 4 becomes 04 / .
- Every input has inputmode="numeric" and the cc-number, cc-exp and cc-csc autocomplete tokens, so browsers and password managers can fill it.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/card-field.tsx353 lines
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ChangeEvent, ComponentProps, KeyboardEvent, ReactNode } from "react";
import "./card-field.css";
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
type Brand = {
name: string;
pattern: RegExp;
lengths: number[];
/** Digit counts of each group, e.g. 4-6-5 for American Express. */
groups: number[];
cvc: number;
};
/* Prefixes from the networks' published ranges. Order matters: the first match wins. */
const BRANDS: Brand[] = [
{ name: "American Express", pattern: /^3[47]/, lengths: [15], groups: [4, 6, 5], cvc: 4 },
{ name: "Diners Club", pattern: /^3(0[0-5]|[689])/, lengths: [14, 16, 19], groups: [4, 6, 4, 5], cvc: 3 },
{ name: "JCB", pattern: /^35(2[89]|[3-8])/, lengths: [16, 17, 18, 19], groups: [4, 4, 4, 4, 3], cvc: 3 },
{ name: "UnionPay", pattern: /^62/, lengths: [16, 17, 18, 19], groups: [4, 4, 4, 4, 3], cvc: 3 },
{ name: "Maestro", pattern: /^(5018|5020|5038|5893|6304|6759|676[1-3])/, lengths: [12, 13, 14, 15, 16, 17, 18, 19], groups: [4, 4, 4, 4, 3], cvc: 3 },
{ name: "Discover", pattern: /^(6011|64[4-9]|65)/, lengths: [16, 19], groups: [4, 4, 4, 4, 3], cvc: 3 },
{ name: "Mastercard", pattern: /^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)/, lengths: [16], groups: [4, 4, 4, 4], cvc: 3 },
{ name: "Visa", pattern: /^4/, lengths: [13, 16, 19], groups: [4, 4, 4, 4, 3], cvc: 3 },
];
const FALLBACK = { lengths: [12, 13, 14, 15, 16, 17, 18, 19], groups: [4, 4, 4, 4, 3], cvc: 3 };
export function detectBrand(digits: string): Brand | null {
return BRANDS.find((brand) => brand.pattern.test(digits)) ?? null;
}
/** The Luhn checksum every card number carries in its last digit. */
export function luhn(digits: string): boolean {
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let n = Number(digits[digits.length - 1 - i]);
if (i % 2 === 1) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
}
return digits.length > 0 && sum % 10 === 0;
}
const onlyDigits = (text: string) => text.replace(/\D/g, "");
function formatNumber(digits: string, groups: number[]) {
const out: string[] = [];
let at = 0;
for (const size of groups) {
if (at >= digits.length) break;
out.push(digits.slice(at, at + size));
at += size;
}
return out.join(" ");
}
/** "1228" and "122028" both become "12 / 28" or "12 / 2028"; a leading 2-9 is read as a month. */
function formatExpiry(digits: string, growing: boolean) {
let d = digits;
if (/^[2-9]/.test(d)) d = `0${d}`;
if (/^1[3-9]/.test(d)) d = `0${d}`;
d = d.slice(0, 6);
if (d.length > 2) return `${d.slice(0, 2)} / ${d.slice(2)}`;
if (d.length === 2 && growing) return `${d} / `;
return d;
}
/** Month and full year, or null while it cannot be a date yet. */
function parseExpiry(text: string): { month: number; year: number } | null {
const d = onlyDigits(text);
if (d.length !== 4 && d.length !== 6) return null;
const month = Number(d.slice(0, 2));
const year = d.length === 4 ? 2000 + Number(d.slice(2)) : Number(d.slice(2));
if (month < 1 || month > 12) return null;
return { month, year };
}
/** Index in the formatted text just after the nth digit, so the caret stays with what was typed. */
function caretAfterDigits(text: string, count: number) {
if (count <= 0) return 0;
let seen = 0;
for (let i = 0; i < text.length; i++) {
if (/\d/.test(text[i])) seen++;
if (seen === count) return i + 1;
}
return text.length;
}
export type CardValue = {
/** Digits only. */
number: string;
/** "MM/YY" or "MM/YYYY" as typed, without spaces. */
expiry: string;
cvc: string;
/** Detected network, or null. */
brand: string | null;
/** True when all three parts pass every check. */
complete: boolean;
};
type Key = "number" | "expiry" | "cvc";
export type CardFieldProps = Omit<ComponentProps<"div">, "onChange" | "defaultValue"> & {
/** Visible label for the whole group. */
label: ReactNode;
hint?: ReactNode;
/** Error text from you, such as a decline. Shown in place of the field's own messages. */
error?: ReactNode;
/** Form names of the three inputs. */
names?: Partial<Record<Key, string>>;
defaultValue?: Partial<Record<Key, string>>;
onValueChange?: (value: CardValue) => void;
/** All three parts must be filled. Without it, an empty field is valid and a partly filled one is not. */
required?: boolean;
disabled?: boolean;
};
export function CardField({
label,
hint,
error,
names,
defaultValue,
onValueChange,
required = false,
disabled = false,
className,
...rest
}: CardFieldProps) {
const id = useId();
const labelId = `${id}-label`;
const hintId = present(hint) ? `${id}-hint` : undefined;
const errorId = `${id}-error`;
const brandId = `${id}-brand`;
const startDigits = onlyDigits(defaultValue?.number ?? "");
const startBrand = detectBrand(startDigits);
const [number, setNumber] = useState(() => formatNumber(startDigits, (startBrand ?? FALLBACK).groups));
const [expiry, setExpiry] = useState(() => formatExpiry(onlyDigits(defaultValue?.expiry ?? ""), false));
const [cvc, setCvc] = useState(() => onlyDigits(defaultValue?.cvc ?? "").slice(0, 4));
const [touched, setTouched] = useState<Record<Key, boolean>>({ number: false, expiry: false, cvc: false });
const [messages, setMessages] = useState<Record<Key, string>>({ number: "", expiry: "", cvc: "" });
const refs = {
number: useRef<HTMLInputElement>(null),
expiry: useRef<HTMLInputElement>(null),
cvc: useRef<HTMLInputElement>(null),
};
const caret = useRef<{ key: Key; digits: number } | null>(null);
const mounted = useRef(false);
const digits = onlyDigits(number);
const brand = detectBrand(digits);
const spec = brand ?? FALLBACK;
// Reformatting rewrites the value, which would throw the caret to the end; put it back after its digit.
useLayoutEffect(() => {
const pending = caret.current;
if (!pending) return;
caret.current = null;
const input = refs[pending.key].current;
if (!input || document.activeElement !== input) return;
const at = caretAfterDigits(input.value, pending.digits);
input.setSelectionRange(at, at);
});
// Checks run after render, never during it, since the expiry check reads today's date.
useEffect(() => {
const anything = digits !== "" || expiry !== "" || cvc !== "";
const needed = required || anything;
const next: Record<Key, string> = { number: "", expiry: "", cvc: "" };
if (digits === "") next.number = needed ? "Enter the card number." : "";
else if (!spec.lengths.includes(digits.length)) next.number = digits.length < Math.min(...spec.lengths) ? "The card number is too short." : "Check the card number.";
else if (!luhn(digits)) next.number = "Check the card number. It does not add up.";
const parsed = parseExpiry(expiry);
if (expiry === "") next.expiry = needed ? "Enter the expiry date." : "";
else if (!parsed) next.expiry = "Enter the expiry as month and year, like 08 / 29.";
else {
const now = new Date();
const current = now.getFullYear() * 12 + now.getMonth();
const card = parsed.year * 12 + parsed.month - 1;
if (card < current) next.expiry = "This card has expired.";
else if (card > current + 12 * 20) next.expiry = "Check the expiry year.";
}
if (cvc === "") next.cvc = needed ? "Enter the security code." : "";
else if (cvc.length !== spec.cvc) next.cvc = `The security code is ${spec.cvc} digits.`;
(Object.keys(next) as Key[]).forEach((key) => refs[key].current?.setCustomValidity(next[key]));
setMessages(next);
if (!mounted.current) {
mounted.current = true;
return;
}
onValueChange?.({
number: digits,
expiry: expiry.replace(/\s/g, ""),
cvc,
brand: brand?.name ?? null,
complete: anything && !next.number && !next.expiry && !next.cvc,
});
// Runs when the text changes, so an inline onValueChange from the caller does not retrigger it.
}, [digits, expiry, cvc, required, spec.cvc]);
function remember(key: Key, event: ChangeEvent<HTMLInputElement>) {
const input = event.currentTarget;
const before = input.value.slice(0, input.selectionStart ?? input.value.length);
caret.current = { key, digits: onlyDigits(before).length };
}
function advance(key: Key) {
const next = refs[key].current;
if (next && !next.disabled) {
next.focus();
next.select();
}
}
function handleNumber(event: ChangeEvent<HTMLInputElement>) {
remember("number", event);
const raw = onlyDigits(event.currentTarget.value);
const nextBrand = detectBrand(raw) ?? FALLBACK;
const nextDigits = raw.slice(0, Math.max(...nextBrand.lengths));
setNumber(formatNumber(nextDigits, nextBrand.groups));
const atEnd = (event.currentTarget.selectionStart ?? 0) >= event.currentTarget.value.length;
// Jump on once the number is a full, passing length: the longest for its brand, or the usual 16.
// A number that fails the checksum stays put, so a typo is not carried along.
const full = nextDigits.length === Math.max(...nextBrand.lengths) || nextDigits.length === 16;
if (atEnd && full && nextBrand.lengths.includes(nextDigits.length) && luhn(nextDigits)) advance("expiry");
}
function handleExpiry(event: ChangeEvent<HTMLInputElement>) {
remember("expiry", event);
const native = event.nativeEvent as InputEvent;
const growing = !native.inputType?.startsWith("delete");
let raw = onlyDigits(event.currentTarget.value);
// Deleting the space before the slash also takes the month's last digit with it.
if (!growing && expiry.endsWith(" / ") && raw.length === 2) raw = raw.slice(0, 1);
const text = formatExpiry(raw, growing);
setExpiry(text);
const d = onlyDigits(text);
// Four digits may still be the start of a four-digit year, as in 12 / 20, so wait for it.
if (growing && (d.length === 6 || (d.length === 4 && d.slice(2) !== "20"))) advance("cvc");
}
function handleCvc(event: ChangeEvent<HTMLInputElement>) {
remember("cvc", event);
setCvc(onlyDigits(event.currentTarget.value).slice(0, spec.cvc));
}
/** Backspace in an empty part goes back to the one before, as if the three were one line. */
function handleKeyDown(key: Key, event: KeyboardEvent<HTMLInputElement>) {
const input = event.currentTarget;
if (event.key !== "Backspace" || input.selectionStart !== 0 || input.selectionEnd !== 0) return;
const previous = key === "cvc" ? refs.expiry.current : key === "expiry" ? refs.number.current : null;
if (!previous) return;
event.preventDefault();
previous.focus();
const end = previous.value.length;
previous.setSelectionRange(end, end);
}
const shown = (key: Key) => touched[key] && messages[key] !== "";
const firstShown = (["number", "expiry", "cvc"] as Key[]).find(shown);
const message = present(error) ? error : firstShown ? messages[firstShown] : null;
const describedBy = [hintId, present(message) ? errorId : undefined].filter(Boolean).join(" ") || undefined;
const invalid = (key: Key) => (present(error) || shown(key) ? true : undefined);
const touch = (key: Key) => setTouched((t) => (t[key] ? t : { ...t, [key]: true }));
const shared = (key: Key) => ({
ref: refs[key],
className: "eq-card-field-input",
"data-part": key,
type: "text",
inputMode: "numeric" as const,
spellCheck: false,
required,
disabled,
"aria-invalid": invalid(key),
"aria-describedby": describedBy,
onBlur: () => touch(key),
// A failed submit marks the part, so its message shows even if it was never visited.
onInvalid: () => touch(key),
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => handleKeyDown(key, event),
});
return (
<div
{...rest}
className={["eq-card-field", className].filter(Boolean).join(" ")}
data-disabled={disabled || undefined}
>
<span className="eq-card-field-label" id={labelId}>
{label}
</span>
{/* Beside the label rather than in the box, so a long number never has to make room for it. */}
<span className="eq-card-field-brand" id={brandId} aria-live="polite">
{brand?.name ?? ""}
</span>
<div className="eq-card-field-control" role="group" aria-labelledby={labelId} aria-describedby={describedBy}>
<input
{...shared("number")}
name={names?.number ?? "cardnumber"}
autoComplete="cc-number"
aria-label="Card number"
aria-describedby={[brandId, describedBy].filter(Boolean).join(" ")}
placeholder="1234 1234 1234 1234"
value={number}
onChange={handleNumber}
/>
<input
{...shared("expiry")}
name={names?.expiry ?? "exp-date"}
autoComplete="cc-exp"
aria-label="Expiry date, month and year"
placeholder="MM / YY"
maxLength={9}
value={expiry}
onChange={handleExpiry}
/>
<input
{...shared("cvc")}
name={names?.cvc ?? "cvc"}
autoComplete="cc-csc"
aria-label={`Security code, ${spec.cvc} digits`}
placeholder={spec.cvc === 4 ? "1234" : "CVC"}
maxLength={4}
value={cvc}
onChange={handleCvc}
/>
</div>
{present(hint) && (
<p className="eq-card-field-hint" id={hintId}>
{hint}
</p>
)}
<p className="eq-card-field-error" id={errorId} hidden={!present(message)}>
{message}
</p>
</div>
);
}components/equip/card-field.css134 lines
@layer equip {
.eq-card-field {
container-type: inline-size;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: var(--eq-space-1) var(--eq-space-2);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
/* Label and brand share the first row; everything else spans both columns. */
.eq-card-field > :not(.eq-card-field-label, .eq-card-field-brand) {
grid-column: 1 / -1;
}
.eq-card-field-label {
justify-self: start;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
/* One bordered box; the three inputs inside are bare and split by hairlines. */
.eq-card-field-control {
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) calc(9ch + var(--eq-space-4)) calc(4.5ch + var(--eq-space-4));
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.2 var(--eq-font);
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-card-field-input {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: calc(var(--eq-h-m) - 2px);
margin: 0;
padding: 0 var(--eq-space-3);
border: 0;
border-radius: 0;
background: transparent;
appearance: none;
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
outline: none;
}
.eq-card-field-input[data-part="expiry"],
.eq-card-field-input[data-part="cvc"] {
padding-inline: var(--eq-space-2);
border-inline-start: 1px solid var(--eq-line);
}
.eq-card-field-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
/* The network name, as plain text. Empty until the prefix gives it away. */
.eq-card-field-brand {
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
white-space: nowrap;
user-select: none;
}
/* Narrow: the number takes the first row, expiry and code share the second. */
@container (width < 300px) {
.eq-card-field-control {
grid-template-columns: 1fr 1fr;
}
.eq-card-field-input[data-part="number"] {
grid-column: 1 / -1;
border-block-end: 1px solid var(--eq-line);
}
.eq-card-field-input[data-part="expiry"] {
padding-inline: var(--eq-space-3);
border-inline-start: 0;
}
.eq-card-field-input[data-part="cvc"] {
padding-inline: var(--eq-space-3);
}
}
@media (hover: hover) {
/* :where keeps this below the invalid rules, so a red border stays red on hover. */
.eq-card-field-control:hover:where(:not(:has(.eq-card-field-input:disabled))) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
/* The ring sits on the shared box, as on Field. */
.eq-card-field-control:has(.eq-card-field-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-card-field-control:has(.eq-card-field-input[aria-invalid="true"]),
.eq-card-field-control:has(.eq-card-field-input:user-invalid) {
border-color: var(--eq-danger);
}
.eq-card-field[data-disabled] .eq-card-field-control {
opacity: 0.45;
cursor: not-allowed;
}
.eq-card-field-input:disabled {
cursor: not-allowed;
}
.eq-card-field-hint,
.eq-card-field-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-card-field-hint {
color: var(--eq-ink-2);
}
.eq-card-field-error {
color: var(--eq-danger);
}
}