Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Eine Zeile

Nummer, Ablauf und Prüfnummer in einer Zeile, die letzten beiden gleiten herein, sobald die Nummer steht. Auf dem Telefon zwei Reihen in einem Feld. Der Zahlknopf erwacht, wenn alles stimmt.

Im Studio öffnen

Untereinander

Jeder Teil mit eigenem Label, der Name darunter, eine Option, die Karte zu behalten. Eine vollständige Nummer zeichnet einen Haken, die Rücktaste in einem leeren Teil geht zum vorigen.

Im Studio öffnen

Kartenvorschau

Eine gezeichnete Karte folgt jeder Taste, das Ablaufdatum leuchtet beim Tippen auf, und für die Prüfnummer dreht sich die Karte auf die Rückseite.

Im Studio öffnen

Gespeicherte Karten

Hinterlegte Karten zur Wahl, mit Endziffern und Ablauf, die Standardkarte markiert. Eine neue Karte klappt ihre Felder mit weicher Höhe auf.

Im Studio öffnen

Details

Was Kartenfeld mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/card-input.tsx
Bringt mit
utilskiticoninputcheckbox
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 18 eigene Texte

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.

  • variantoptional
    CardInputVariant

    singlestackedpreviewsaved

  • onValueChangeoptional
    (value: CardValue) => void
  • withNameoptional
    boolean

    stacked, preview: ask for the name on the card

  • savedCardsoptional
    SavedCard[]

    saved: cards on file

  • defaultSelectedoptional
    string

    saved: the chosen card id, or "new"

  • onSelectoptional
    (id: string) => void
  • defaultNumberoptional
    string

    Start values, for demos and edits

  • defaultExpiryoptional
    string
  • defaultNameoptional
    string
  • rememberOptionoptional
    boolean

    stacked, saved: a checkbox to keep the card

  • disabledoptional
    boolean
  • invalidoptional
    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.

Eingebaute Texte

Diese Texte schreibt Kartenfeld selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 18 Texte anzeigen
numberEnglisch Card numberDeutsch Kartennummer
expiryEnglisch ExpiryDeutsch Gültig bis
expiryHintEnglisch MM / YYDeutsch MM / JJ
cvcEnglisch Security codeDeutsch Prüfnummer
nameEnglisch Name on cardDeutsch Name auf der Karte
nameHintEnglisch As printed on the cardDeutsch Wie auf der Karte gedruckt
badNumberEnglisch This card number is not complete or has a typo.Deutsch Diese Kartennummer ist unvollständig oder enthält einen Tippfehler.
badExpiryEnglisch 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.
badCvcEnglisch 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.
backEnglisch The code is on the back of the cardDeutsch Die Prüfnummer steht auf der Rückseite
savedEnglisch Saved cardsDeutsch Gespeicherte Karten
newCardEnglisch Use a new cardDeutsch Neue Karte nutzen
endsEnglisch ending inDeutsch endet auf
expiresEnglisch expiresDeutsch gültig bis
validEnglisch Card details completeDeutsch Kartendaten vollständig
defaultEnglisch DefaultDeutsch Standard
cardholderEnglisch CardholderDeutsch Karteninhaber
rememberEnglisch Save this card for next timeDeutsch 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
components/ui/card-input.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert