Kartenfeld
Kartennummer, Ablaufdatum und Prüfnummer mit Gruppierung, Luhn-Prüfung, automatischem Weiterspringen und klaren Meldungen, ohne Netzwerknamen oder Logos.
Eine von 6 Components in Spezielle Eingabefelder
Variants
Details
Was Kartenfeld mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Spezielle Eingabefelder
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/card-input.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 18 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CardInputProps aus components/ui/card-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.
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.
Eingebaute Texte
Diese Texte schreibt Kartenfeld selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 18 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| number | Englisch Card number | Deutsch Kartennummer |
| expiry | Englisch Expiry | Deutsch Gültig bis |
| expiryHint | Englisch MM / YY | Deutsch MM / JJ |
| cvc | Englisch Security code | Deutsch Prüfnummer |
| name | Englisch Name on card | Deutsch Name auf der Karte |
| nameHint | Englisch As printed on the card | Deutsch Wie auf der Karte gedruckt |
| badNumber | Englisch This card number is not complete or has a typo. | Deutsch Diese Kartennummer ist unvollständig oder enthält einen Tippfehler. |
| badExpiry | Englisch This date has passed. Check the month and year on the card. | Deutsch Dieses Datum ist vorbei. Prüf Monat und Jahr auf der Karte. |
| badCvc | Englisch The code has 3 digits on the back, or 4 on the front. | Deutsch Die Prüfnummer hat 3 Ziffern auf der Rückseite oder 4 auf der Vorderseite. |
| back | Englisch The code is on the back of the card | Deutsch Die Prüfnummer steht auf der Rückseite |
| saved | Englisch Saved cards | Deutsch Gespeicherte Karten |
| newCard | Englisch Use a new card | Deutsch Neue Karte nutzen |
| ends | Englisch ending in | Deutsch endet auf |
| expires | Englisch expires | Deutsch gültig bis |
| valid | Englisch Card details complete | Deutsch Kartendaten vollständig |
| default | Englisch Default | Deutsch Standard |
| cardholder | Englisch Cardholder | Deutsch Karteninhaber |
| remember | Englisch Save this card for next time | Deutsch Karte für das nächste Mal sichern |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Kartenfeld reagiert selbst auf die Tasten Rücktaste und Leertaste.
- Setzt aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 506 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Kartenfeld?
Mit aiw add @ai-website/card-input. Der Befehl legt die Datei components/ui/card-input.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input und checkbox. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Kartenfeld?
4 Varianten, Eine Zeile, Untereinander, Kartenvorschau und Gespeicherte Karten. Du wählst sie mit der Prop variant, zum Beispiel variant="preview".
Kann ich die Texte von Kartenfeld übersetzen?
Ja. Kartenfeld bringt 18 eigene Texte auf Deutsch und Englisch mit, etwa „Kartennummer“, „Gültig bis“ und „MM / JJ“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Kartenfeld einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kartenfeld mit aiw_components und installieren sie mit aiw_add @ai-website/card-input. Nenn im Prompt die Variante, etwa variant="stacked", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Kartenfeld ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden CardInput-Code zum Kopieren.