Skip to content
UI Kit
Accent color

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

4 variants

Variants

One line

Number, expiry and code in one line, the last two slide in once the number is there. On phones two rows in one box. The pay button wakes up when everything checks out.

Open in Studio

Stacked

Each part with its own label, the name below, an option to keep the card. A complete number draws a check, Backspace in an empty part goes back to the one before.

Open in Studio

Card preview

A drawn card follows every key, the expiry lights up while you type it, and the card turns over for the security code on the back.

Open in Studio

Saved cards

Cards on file as choices with the last digits and expiry, the default marked. A new card unfolds its fields with a soft height.

Open in Studio

Details

What Card input brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/card-input.tsx
Brings along
utilskiticoninputcheckbox
npm packages
None
Languages
English and German, 18 texts of its own

Props

The CardInputProps interface from components/ui/card-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • 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.

Built-in texts

Card input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 18 texts
numberEnglish Card numberGerman Kartennummer
expiryEnglish ExpiryGerman Gültig bis
expiryHintEnglish MM / YYGerman MM / JJ
cvcEnglish Security codeGerman Prüfnummer
nameEnglish Name on cardGerman Name auf der Karte
nameHintEnglish As printed on the cardGerman Wie auf der Karte gedruckt
badNumberEnglish This card number is not complete or has a typo.German Diese Kartennummer ist unvollständig oder enthält einen Tippfehler.
badExpiryEnglish 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.
badCvcEnglish 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.
backEnglish The code is on the back of the cardGerman Die Prüfnummer steht auf der Rückseite
savedEnglish Saved cardsGerman Gespeicherte Karten
newCardEnglish Use a new cardGerman Neue Karte nutzen
endsEnglish ending inGerman endet auf
expiresEnglish expiresGerman gültig bis
validEnglish Card details completeGerman Kartendaten vollständig
defaultEnglish DefaultGerman Standard
cardholderEnglish CardholderGerman Karteninhaber
rememberEnglish Save this card for next timeGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard