Card input
Card number, expiry and security code with grouping, a Luhn check, auto advance and clear messages, without network names or logos.
One of 6 components in Special input fields
Variants
Details
What Card input brings along, read from the registry and the source.
- Category
- Special input fields
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/card-input.tsx
- npm packages
- None
- Languages
- English and German, 18 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CardInputProps interface from components/ui/card-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
variantoptionalCardInputVariantsinglestackedpreviewsaved
onValueChangeoptional(value: CardValue) => voidwithNameoptionalbooleanstacked, preview: ask for the name on the card
savedCardsoptionalSavedCard[]saved: cards on file
defaultSelectedoptionalstringsaved: the chosen card id, or "new"
onSelectoptional(id: string) => voiddefaultNumberoptionalstringStart values, for demos and edits
defaultExpiryoptionalstringdefaultNameoptionalstringrememberOptionoptionalbooleanstacked, saved: a checkbox to keep the card
disabledoptionalbooleaninvalidoptionalbooleanMarks the card as missing or incomplete as a whole, e.g. after a submit with empty fields. Every part that is not complete gets aria-invalid and the red edge. `error` does the same and shows its text under the fields.
Built-in texts
Card input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 18 texts
| Key | English | German |
|---|---|---|
| number | English Card number | German Kartennummer |
| expiry | English Expiry | German Gültig bis |
| expiryHint | English MM / YY | German MM / JJ |
| cvc | English Security code | German Prüfnummer |
| name | English Name on card | German Name auf der Karte |
| nameHint | English As printed on the card | German Wie auf der Karte gedruckt |
| badNumber | English This card number is not complete or has a typo. | German Diese Kartennummer ist unvollständig oder enthält einen Tippfehler. |
| badExpiry | English This date has passed. Check the month and year on the card. | German Dieses Datum ist vorbei. Prüf Monat und Jahr auf der Karte. |
| badCvc | English The code has 3 digits on the back, or 4 on the front. | German Die Prüfnummer hat 3 Ziffern auf der Rückseite oder 4 auf der Vorderseite. |
| back | English The code is on the back of the card | German Die Prüfnummer steht auf der Rückseite |
| saved | English Saved cards | German Gespeicherte Karten |
| newCard | English Use a new card | German Neue Karte nutzen |
| ends | English ending in | German endet auf |
| expires | English expires | German gültig bis |
| valid | English Card details complete | German Kartendaten vollständig |
| default | English Default | German Standard |
| cardholder | English Cardholder | German Karteninhaber |
| remember | English Save this card for next time | German Karte für das nächste Mal sichern |
Accessibility
Read from the source of the component.
- Card input handles the keys Backspace and Space itself.
- Sets aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role group.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 506 lines
'use client';
import { useId, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { SuccessMark, controlBox, useShake, type FieldOwnProps } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
/*
* Payment card details: number, expiry and security code. The number groups
* itself (4 4 4 4, or 4 6 5 for 15 digit cards), the Luhn check runs on
* completion, the expiry turns 0 into 0 and 1 into 01 /, and the focus moves
* on by itself once a part is complete. No card network names or logos.
* The card data never leaves the component unless you pass onValueChange.
* Real payments belong to your payment provider's hosted fields, this is the
* look and the behaviour around them.
*/
const TEXTS = {
en: {
number: 'Card number', expiry: 'Expiry', expiryHint: 'MM / YY', cvc: 'Security code', name: 'Name on card', nameHint: 'As printed on the card',
badNumber: 'This card number is not complete or has a typo.', badExpiry: 'This date has passed. Check the month and year on the card.', badCvc: 'The code has 3 digits on the back, or 4 on the front.',
back: 'The code is on the back of the card', saved: 'Saved cards', newCard: 'Use a new card', ends: 'ending in', expires: 'expires', valid: 'Card details complete', default: 'Default',
cardholder: 'Cardholder', remember: 'Save this card for next time',
},
de: {
number: 'Kartennummer', expiry: 'Gültig bis', expiryHint: 'MM / JJ', cvc: 'Prüfnummer', name: 'Name auf der Karte', nameHint: 'Wie auf der Karte gedruckt',
badNumber: 'Diese Kartennummer ist unvollständig oder enthält einen Tippfehler.', badExpiry: 'Dieses Datum ist vorbei. Prüf Monat und Jahr auf der Karte.', badCvc: 'Die Prüfnummer hat 3 Ziffern auf der Rückseite oder 4 auf der Vorderseite.',
back: 'Die Prüfnummer steht auf der Rückseite', saved: 'Gespeicherte Karten', newCard: 'Neue Karte nutzen', ends: 'endet auf', expires: 'gültig bis', valid: 'Kartendaten vollständig', default: 'Standard',
cardholder: 'Karteninhaber', remember: 'Karte für das nächste Mal sichern',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Card rules */
/* ------------------------------------------------------------------ */
const digits = (s: string) => s.replace(/\D/g, '');
/** 15 digit cards start with 34 or 37, they group 4 6 5 and use a 4 digit code on the front */
const isLong = (d: string) => /^3[47]/.test(d);
export const cardLength = (d: string) => (isLong(d) ? 15 : 16);
export const cvcLength = (d: string) => (isLong(d) ? 4 : 3);
export function formatCardNumber(d: string) {
const groups = isLong(d) ? [4, 6, 5] : [4, 4, 4, 4];
const out: string[] = [];
let i = 0;
for (const g of groups) {
if (i >= d.length) break;
out.push(d.slice(i, i + g));
i += g;
}
return out.join(' ');
}
/** The Luhn checksum every card number carries */
export function luhn(d: string) {
let sum = 0;
for (let i = 0; i < d.length; i++) {
let n = Number(d[d.length - 1 - i]);
if (i % 2) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
}
return d.length > 0 && sum % 10 === 0;
}
/** "0" stays, "1" stays, "2" becomes "02 / " */
function formatExpiry(raw: string, prev: string) {
let d = digits(raw).slice(0, 4);
if (d.length === 1 && Number(d) > 1) d = `0${d}`;
if (d.length >= 2 && Number(d.slice(0, 2)) > 12) d = `0${d[0]}${d.slice(1)}`.slice(0, 4);
const deleting = raw.length < prev.length;
if (d.length > 2 || (d.length === 2 && !deleting)) return `${d.slice(0, 2)} / ${d.slice(2)}`.trimEnd();
return d;
}
function expiryOk(text: string, now = new Date()) {
const d = digits(text);
if (d.length !== 4) return false;
const m = Number(d.slice(0, 2));
const y = 2000 + Number(d.slice(2));
if (m < 1 || m > 12) return false;
return y > now.getFullYear() || (y === now.getFullYear() && m >= now.getMonth() + 1);
}
export interface CardValue {
number: string;
expiry: string;
cvc: string;
name?: string;
complete: boolean;
}
export interface SavedCard {
id: string;
/** Last four digits */
last4: string;
/** "MM/YY" */
expiry: string;
/** Who holds it, or a nickname */
label?: string;
/** Marks the default card */
isDefault?: boolean;
}
export type CardInputVariant = 'single' | 'stacked' | 'preview' | 'saved';
export interface CardInputProps extends FieldOwnProps {
variant?: CardInputVariant;
onValueChange?: (value: CardValue) => void;
/** stacked, preview: ask for the name on the card */
withName?: boolean;
/** saved: cards on file */
savedCards?: SavedCard[];
/** saved: the chosen card id, or "new" */
defaultSelected?: string;
onSelect?: (id: string) => void;
/** Start values, for demos and edits */
defaultNumber?: string;
defaultExpiry?: string;
defaultName?: string;
/** stacked, saved: a checkbox to keep the card */
rememberOption?: boolean;
disabled?: boolean;
/**
* Marks the card as missing or incomplete as a whole, e.g. after a submit with
* empty fields. Every part that is not complete gets aria-invalid and the red
* edge. `error` does the same and shows its text under the fields.
*/
invalid?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/** The card number, expiry and code with their own focus order and checks */
function useCard(props: CardInputProps) {
const [number, setNumber] = useState(digits(props.defaultNumber ?? ''));
const [expiry, setExpiry] = useState(props.defaultExpiry ? formatExpiry(props.defaultExpiry, '') : '');
const [cvc, setCvc] = useState('');
const [name, setName] = useState(props.defaultName ?? '');
const [touched, setTouched] = useState<Record<string, boolean>>({});
const len = cardLength(number);
const numberDone = number.length === len;
const numberOk = numberDone && luhn(number);
const expiryDone = digits(expiry).length === 4;
const expOk = expiryOk(expiry);
const cvcOk = cvc.length === cvcLength(number);
const complete = numberOk && expOk && cvcOk && (!props.withName || name.trim().length > 1);
// complete is worked out from the new values, not from the last render
const report = (patch: Partial<CardValue>) => {
const v = { number, expiry: digits(expiry), cvc, name, ...patch };
const ok = v.number.length === cardLength(v.number) && luhn(v.number) && expiryOk(v.expiry) && v.cvc.length === cvcLength(v.number) && (!props.withName || (v.name ?? '').trim().length > 1);
props.onValueChange?.({ ...v, complete: ok });
};
return {
number, expiry, cvc, name, len, numberDone, numberOk, expiryDone, expOk, cvcOk, complete, touched,
touch: (k: string) => setTouched((t) => ({ ...t, [k]: true })),
setNumber: (v: string) => { const d = digits(v).slice(0, cardLength(digits(v))); setNumber(d); report({ number: d }); return d; },
setExpiry: (v: string) => { const e = formatExpiry(v, expiry); setExpiry(e); report({ expiry: digits(e) }); return e; },
setCvc: (v: string) => { const c = digits(v).slice(0, cvcLength(number)); setCvc(c); report({ cvc: c }); return c; },
setName: (v: string) => { setName(v); report({ name: v }); },
};
}
type Card = ReturnType<typeof useCard>;
const inputCls = 'h-full min-w-0 bg-transparent tabular-nums text-inherit outline-none placeholder:text-subtle-foreground disabled:cursor-not-allowed';
/** Number, expiry and code fields wired to move on by themselves */
function useParts(card: Card, t: Texts, ids: { number: string; expiry: string; cvc: string }, disabled?: boolean, onFocusPart?: (p: 'number' | 'expiry' | 'cvc' | null) => void, missing?: boolean, describedBy?: string) {
const numberRef = useRef<HTMLInputElement>(null);
const expiryRef = useRef<HTMLInputElement>(null);
const cvcRef = useRef<HTMLInputElement>(null);
const numberBad = card.touched.number && card.number.length > 0 && !card.numberOk;
const expiryBad = card.touched.expiry && card.expiry.length > 0 && !card.expOk;
const cvcBad = card.touched.cvc && card.cvc.length > 0 && !card.cvcOk;
const errors = [numberBad && t.badNumber, expiryBad && t.badExpiry, cvcBad && t.badCvc].filter(Boolean) as string[];
// Missing as a whole: every part that is not complete counts as invalid, the text comes from `error`
const bad = { number: numberBad || (Boolean(missing) && !card.numberOk), expiry: expiryBad || (Boolean(missing) && !card.expOk), cvc: cvcBad || (Boolean(missing) && !card.cvcOk) };
const back = (e: React.KeyboardEvent<HTMLInputElement>, prev: React.RefObject<HTMLInputElement | null>) => {
if (e.key === 'Backspace' && e.currentTarget.value === '') {
e.preventDefault();
prev.current?.focus();
}
};
const number = (cls?: string) => (
<input
ref={numberRef}
id={ids.number}
inputMode="numeric"
autoComplete="cc-number"
aria-label={t.number}
aria-invalid={bad.number || undefined}
aria-describedby={bad.number ? describedBy : undefined}
disabled={disabled}
placeholder="1234 1234 1234 1234"
value={formatCardNumber(card.number)}
onFocus={() => onFocusPart?.('number')}
onBlur={() => { card.touch('number'); onFocusPart?.(null); }}
onChange={(e) => {
const d = card.setNumber(e.target.value);
if (d.length === cardLength(d) && luhn(d)) expiryRef.current?.focus();
}}
className={cn(inputCls, cls)}
/>
);
const expiry = (cls?: string) => (
<input
ref={expiryRef}
id={ids.expiry}
inputMode="numeric"
autoComplete="cc-exp"
aria-label={t.expiry}
aria-invalid={bad.expiry || undefined}
aria-describedby={bad.expiry ? describedBy : undefined}
disabled={disabled}
placeholder={t.expiryHint}
value={card.expiry}
onFocus={() => onFocusPart?.('expiry')}
onBlur={() => { card.touch('expiry'); onFocusPart?.(null); }}
onKeyDown={(e) => back(e, numberRef)}
onChange={(e) => {
const v = card.setExpiry(e.target.value);
if (digits(v).length === 4 && expiryOk(v)) cvcRef.current?.focus();
}}
className={cn(inputCls, cls)}
/>
);
const cvc = (cls?: string) => (
<input
ref={cvcRef}
id={ids.cvc}
inputMode="numeric"
autoComplete="cc-csc"
aria-label={t.cvc}
aria-invalid={bad.cvc || undefined}
aria-describedby={bad.cvc ? describedBy : undefined}
disabled={disabled}
placeholder={cvcLength(card.number) === 4 ? '1234' : '123'}
value={card.cvc}
onFocus={() => onFocusPart?.('cvc')}
onBlur={() => { card.touch('cvc'); onFocusPart?.(null); }}
onKeyDown={(e) => back(e, expiryRef)}
onChange={(e) => card.setCvc(e.target.value)}
className={cn(inputCls, cls)}
/>
);
return { number, expiry, cvc, errors, bad };
}
function Errors({ id, errors }: { id: string; errors: React.ReactNode[] }) {
return (
<div id={id} aria-live="polite" className="empty:hidden">
{errors.map((e, i) => (
<p key={typeof e === 'string' ? e : i} className="mt-2 flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive transition-[opacity,translate] duration-300 starting:-translate-y-1 starting:opacity-0">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px]" />
{e}
</p>
))}
</div>
);
}
const FieldLabel = ({ htmlFor, children, className }: { htmlFor?: string; children: React.ReactNode; className?: string }) => (
<label htmlFor={htmlFor} className={cn('text-sm font-medium leading-5 text-foreground', className)}>
{children}
</label>
);
/** A small drawn card face, the magnetic band and chip are plain shapes */
function CardArt({ card, t, flipped, focus }: { card: Card; t: Texts; flipped: boolean; focus: 'number' | 'expiry' | 'cvc' | null }) {
const masked = (() => {
const d = card.number.padEnd(card.len, '•');
return formatCardNumber(d);
})();
const exp = card.expiry || t.expiryHint;
return (
<div className="mx-auto w-full max-w-[22rem] [perspective:1200px]" aria-hidden>
<div
className="relative aspect-[1.586] w-full transition-transform duration-700 ease-[var(--ease-kit)] [transform-style:preserve-3d] motion-reduce:transition-none"
style={{ transform: flipped ? 'rotateY(180deg)' : 'rotateY(0deg)' }}
>
{/* Front */}
<div data-clip-ok className="dark absolute inset-0 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,color-mix(in_oklab,var(--primary)_70%,#0b1020),#0b1020_70%)] p-[6%] text-white shadow-[var(--shadow-lg)] [backface-visibility:hidden]">
<span className="absolute -end-[20%] -top-[40%] size-[80%] rounded-full bg-white/10" />
<span className="absolute -bottom-[50%] -start-[10%] size-[90%] rounded-full bg-white/5" />
<div className="relative flex h-full flex-col justify-between">
<div className="flex items-start justify-between">
<span className="grid h-[22%] w-[15%] min-h-7 grid-cols-2 gap-px overflow-hidden rounded-[5px] bg-[linear-gradient(135deg,#e9d8a6,#c9a96e)] p-[3px] opacity-90">
{[0, 1, 2, 3].map((i) => <span key={i} className="rounded-[1.5px] border border-black/15" />)}
</span>
<Icon name="wifi" size={20} className="rotate-90 opacity-70" />
</div>
<p className={cn('text-[clamp(15px,5vw,21px)] font-medium tracking-[0.08em] tabular-nums transition-colors', focus === 'number' && 'text-white')} style={{ textShadow: '0 1px 2px rgb(0 0 0 / .3)' }}>
{masked}
</p>
<div className="flex items-end justify-between gap-4 text-[11px] uppercase tracking-[0.08em]">
<span className="min-w-0">
<span className="block text-[9px] opacity-60">{t.cardholder}</span>
<span className="block truncate text-[13px] font-medium normal-case tracking-normal">{card.name || '·'}</span>
</span>
<span className={cn('shrink-0 rounded px-1 text-end transition-colors', focus === 'expiry' && 'bg-white/15')}>
<span className="block text-[9px] opacity-60">{t.expiry}</span>
<span className="block text-[13px] font-medium tabular-nums tracking-normal">{exp}</span>
</span>
</div>
</div>
</div>
{/* Back */}
<div className="absolute inset-0 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#1a2238,#0b1020)] text-white shadow-[var(--shadow-lg)] [backface-visibility:hidden] [transform:rotateY(180deg)]">
<div className="mt-[8%] h-[18%] bg-black/80" />
<div className="mx-[6%] mt-[6%] flex h-[16%] items-center justify-end rounded-[4px] bg-[repeating-linear-gradient(90deg,#f4f1ea,#f4f1ea_6px,#e8e3d8_6px,#e8e3d8_12px)] pe-3">
<span className="rounded bg-white px-2 py-0.5 text-[14px] font-semibold tabular-nums tracking-[0.15em] text-[#111]">{card.cvc.padEnd(cvcLength(card.number), '•')}</span>
</div>
<p className="mx-[6%] mt-[5%] text-[11px] opacity-70">{t.back}</p>
</div>
</div>
</div>
);
}
/**
* Payment card number, expiry and code with grouping, checks and auto advance. No network logos.
* <CardInput label="Card" />
* <CardInput variant="stacked" withName rememberOption />
* <CardInput variant="preview" withName />
* <CardInput variant="saved" savedCards={cards} defaultSelected="c1" />
*/
export function CardInput(props: CardInputProps) {
const { variant = 'single', label, hint, error, invalid, withName, savedCards = [], defaultSelected, onSelect, rememberOption, disabled, labels, className } = props;
const t = useTexts(TEXTS, labels);
const card = useCard(props);
const numberLabel = variant !== 'saved' && typeof label === 'string' ? label : t.number;
const base = useId();
const ids = { number: `${base}-number`, expiry: `${base}-expiry`, cvc: `${base}-cvc` };
const [focus, setFocus] = useState<'number' | 'expiry' | 'cvc' | null>(null);
const errId = `${base}-errors`;
const missing = Boolean(error || invalid) && !card.complete;
const parts = useParts(card, t, ids, disabled, setFocus, missing, errId);
const errors: React.ReactNode[] = missing && error ? [error, ...parts.errors] : parts.errors;
const boxRef = useRef<HTMLDivElement>(null);
useShake(boxRef, errors.length > 0 || missing);
const [sel, setSel] = useState(defaultSelected ?? savedCards.find((c) => c.isDefault)?.id ?? savedCards[0]?.id ?? 'new');
const [remember, setRemember] = useState(true);
const lockIcon = <Icon name="lock" size={15} className="shrink-0 text-subtle-foreground" />;
const doneMark = card.complete && (
<span className="flex shrink-0 items-center" title={t.valid}>
<SuccessMark />
<span className="sr-only">{t.valid}</span>
</span>
);
const nameField = withName && (
<div className="grid gap-2">
<FieldLabel htmlFor={`${base}-name`}>{t.name}</FieldLabel>
<div className={cn('flex h-11 items-center rounded-lg px-3.5 text-base sm:text-sm', controlBox)}>
<input id={`${base}-name`} autoComplete="cc-name" disabled={disabled} placeholder={t.nameHint} value={card.name} onChange={(e) => card.setName(e.target.value)} className={cn(inputCls, 'w-0 flex-1')} />
</div>
</div>
);
const rememberBox = rememberOption && <Checkbox label={t.remember} checked={remember} onChange={(e) => setRemember(e.target.checked)} />;
/* ---------- single: everything in one line ---------- */
const single = (
<div className="grid gap-2">
{label && <FieldLabel htmlFor={ids.number}>{label}</FieldLabel>}
<div
ref={boxRef}
role="group"
aria-label={typeof label === 'string' ? label : t.number}
aria-describedby={errId}
data-invalid={errors.length || missing ? true : undefined}
className={cn('flex w-full flex-wrap items-center overflow-hidden rounded-lg text-base sm:h-11 sm:flex-nowrap sm:gap-2 sm:ps-3.5 sm:pe-3 sm:text-sm', controlBox)}
>
{/* One line from 640 up, two rows on phones: the number, then expiry and code */}
<span className="flex h-11 min-w-0 flex-1 basis-full items-center gap-2 px-3.5 sm:basis-auto sm:px-0">
<Icon name="credit-card" size={17} className="shrink-0 text-subtle-foreground" />
{parts.number('w-0 flex-1')}
<span className="sm:hidden">{doneMark}</span>
</span>
{/* Expiry and code slide in once the number is there */}
<span className={cn('flex h-11 shrink-0 items-center transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none max-sm:w-full max-sm:border-t max-sm:border-border sm:gap-2', !card.numberDone && 'sm:pointer-events-none sm:translate-x-3 sm:opacity-60')}>
{parts.expiry('w-[4.6rem] text-center max-sm:h-full max-sm:flex-1 max-sm:border-e max-sm:border-border')}
{parts.cvc('w-12 text-center max-sm:h-full max-sm:flex-1')}
</span>
<span className="max-sm:hidden">{doneMark}</span>
</div>
<Errors id={errId} errors={errors} />
{hint && !errors.length && <p className="text-[13px] text-muted-foreground">{hint}</p>}
</div>
);
/* ---------- stacked: number, then expiry and code ---------- */
const stacked = (
<div className="grid gap-5">
<div className="grid gap-2">
<FieldLabel htmlFor={ids.number}>{numberLabel}</FieldLabel>
<div ref={boxRef} data-invalid={parts.bad.number || undefined} className={cn('flex h-11 items-center gap-2.5 rounded-lg ps-3.5 pe-3 text-base sm:text-sm', controlBox)}>
<Icon name="credit-card" size={17} className="shrink-0 text-subtle-foreground" />
{parts.number('w-0 flex-1')}
{card.numberOk ? <SuccessMark /> : lockIcon}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<FieldLabel htmlFor={ids.expiry}>{t.expiry}</FieldLabel>
<div data-invalid={parts.bad.expiry || undefined} className={cn('flex h-11 items-center rounded-lg px-3.5 text-base sm:text-sm', controlBox)}>{parts.expiry('w-0 flex-1')}</div>
</div>
<div className="grid gap-2">
<FieldLabel htmlFor={ids.cvc}>{t.cvc}</FieldLabel>
<div data-invalid={parts.bad.cvc || undefined} className={cn('flex h-11 items-center gap-2 rounded-lg ps-3.5 pe-3 text-base sm:text-sm', controlBox)}>
{parts.cvc('w-0 flex-1')}
<span title={t.back} className="grid shrink-0 place-items-center text-subtle-foreground">
<Icon name="credit-card" size={16} />
</span>
</div>
</div>
</div>
{nameField}
<Errors id={errId} errors={errors} />
{rememberBox}
</div>
);
if (variant === 'single') return <div className={cn('w-full', className)}>{single}</div>;
if (variant === 'stacked') return <div className={cn('w-full', className)}>{stacked}</div>;
/* ---------- preview: a drawn card that follows and turns for the code ---------- */
if (variant === 'preview') {
return (
<div className={cn('grid w-full grid-cols-[minmax(0,1fr)] items-center gap-8 md:grid-cols-2', className)}>
<CardArt card={card} t={t} flipped={focus === 'cvc'} focus={focus} />
{stacked}
</div>
);
}
/* ---------- saved: cards on file, or a new one ---------- */
return (
<div className={cn('w-full', className)}>
<fieldset>
<legend className="mb-3 text-sm font-medium text-foreground">{typeof label === 'string' ? label : t.saved}</legend>
<div className="grid gap-2">
{[...savedCards.map((c) => c.id), 'new'].map((id) => {
const c = savedCards.find((x) => x.id === id);
const on = sel === id;
return (
<div key={id} className={cn('overflow-hidden rounded-xl border transition-[border-color,box-shadow,background-color] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_10%,transparent)]' : 'border-border bg-card hover:border-border-strong')}>
<label className="flex min-h-14 cursor-pointer items-center gap-3 px-4 py-3">
<input
type="radio"
name={`${base}-card`}
checked={on}
onChange={() => {
setSel(id);
onSelect?.(id);
}}
className="peer sr-only"
/>
{/* The radio drawn by the kit: a ring that fills with a dot */}
<span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center rounded-full border transition-[border-color,background-color] duration-200 peer-focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_60%,transparent)]', on ? 'border-primary bg-primary' : 'border-border-strong bg-background')}>
<span className={cn('size-1.5 rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
</span>
{c ? (
<>
<span aria-hidden className="grid h-8 w-12 shrink-0 place-items-center rounded-md bg-[linear-gradient(135deg,color-mix(in_oklab,var(--primary)_65%,#0b1020),#0b1020)] text-[10px] font-semibold tracking-[0.1em] text-white/90 shadow-[var(--shadow-sm)]">
•• {c.last4.slice(-2)}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-foreground">
<span className="truncate tabular-nums">
<span className="sr-only">{t.ends} </span>
<span aria-hidden className="tracking-[0.12em] text-muted-foreground">•••• </span>
{c.last4}
</span>
{c.isDefault && <span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">{t.default}</span>}
</span>
<span className="block text-[13px] text-muted-foreground">
{c.label ? `${c.label} · ` : ''}
{t.expires} {c.expiry}
</span>
</span>
</>
) : (
<>
<span aria-hidden className="grid h-8 w-12 shrink-0 place-items-center rounded-md border border-dashed border-border-strong text-muted-foreground">
<Icon name="plus" size={15} />
</span>
<span className="flex-1 text-sm font-medium text-foreground">{t.newCard}</span>
</>
)}
</label>
{!c && (
// The new card form unfolds with a soft height
<div className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')} inert={!on || undefined}>
<div className="min-h-0 overflow-hidden">
<div className="border-t border-border px-4 pb-4 pt-4">{stacked}</div>
</div>
</div>
)}
</div>
);
})}
</div>
</fieldset>
</div>
);
}Frequent questions
How do I install Card input?
With aiw add @ai-website/card-input. The command puts components/ui/card-input.tsx into your project, the code is yours from then on. It brings along icon, input and checkbox. It needs no further npm packages.
Which variants does Card input have?
4 variants, One line, Stacked, Card preview and Saved cards. You pick one with the variant prop, for example variant="preview".
Can I translate the texts of Card input?
Yes. Card input carries 18 texts of its own in English and German, such as “Card number”, “Expiry” and “MM / YY”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Card input?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Card input with aiw_components and install it with aiw_add @ai-website/card-input. Name the variant in your prompt, for example variant="stacked", and the assistant writes your content straight into the props.
Can I adjust Card input without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching CardInput code to copy.