Skip to content
38

Credit Card Input

Card number, expiry, and CVC in one row with brand detection, per-brand grouping, Luhn and expiry validation, and auto-advance between fields.

Example

Installation

npx shadcn@latest add https://hirael.com/r/credit-card-input.json

Usage

import {
  CreditCardInput,
  CreditCardInputNumber,
  CreditCardInputExpiry,
  CreditCardInputCvc,
  CreditCardInputBrand,
} from "@/components/credit-card-input"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<CreditCardInput />

+ native element props
PropTypeDefault
idstringnone
valueCreditCardValuenone
defaultValueCreditCardValuenone
onValueChange((value: CreditCardChange) => void)none
variant

`row` is one bordered strip; `stack` leaves each field on its own.

CreditCardInputVariant'row'
disabledbooleannone

<CreditCardInputNumber />

+ native element props
PropTypeDefault
children

Rendered at the end of the field. Defaults to `<CreditCardInputBrand />`.

React.ReactNodenone

<CreditCardInputExpiry />

+ native element props

No props of its own; forwards everything to the underlying element.

<CreditCardInputCvc />

+ native element props

No props of its own; forwards everything to the underlying element.

<CreditCardInputBrand />

+ native element props
PropTypeDefault
labelsPartial<Record<CardBrand, string>>none

Component source

'use client';

import * as React from 'react';
import { CreditCard } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { composeRefs } from '@/registry/hirael/lib/compose-refs';

export type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'diners' | 'jcb' | 'unknown';

export interface CardBrandSpec {
  brand: CardBrand;
  label: string;
  pattern: RegExp;
  lengths: number[];
  /** Digit group sizes used when formatting, e.g. [4, 6, 5] for Amex. */
  groups: number[];
  cvcLength: number;
}

export const CARD_BRANDS: readonly CardBrandSpec[] = [
  {
    brand: 'visa',
    label: 'Visa',
    pattern: /^4/,
    lengths: [13, 16, 19],
    groups: [4, 4, 4, 4, 3],
    cvcLength: 3,
  },
  {
    brand: 'mastercard',
    label: 'Mastercard',
    pattern: /^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)/,
    lengths: [16],
    groups: [4, 4, 4, 4],
    cvcLength: 3,
  },
  {
    brand: 'amex',
    label: 'Amex',
    pattern: /^3[47]/,
    lengths: [15],
    groups: [4, 6, 5],
    cvcLength: 4,
  },
  {
    brand: 'discover',
    label: 'Discover',
    pattern: /^(6011|65|64[4-9]|622)/,
    lengths: [16, 19],
    groups: [4, 4, 4, 4, 3],
    cvcLength: 3,
  },
  {
    brand: 'diners',
    label: 'Diners',
    pattern: /^3(0[0-5]|[68])/,
    lengths: [14, 16, 19],
    groups: [4, 6, 4, 5],
    cvcLength: 3,
  },
  {
    brand: 'jcb',
    label: 'JCB',
    pattern: /^35/,
    lengths: [16, 19],
    groups: [4, 4, 4, 4, 3],
    cvcLength: 3,
  },
];

const UNKNOWN_BRAND: CardBrandSpec = {
  brand: 'unknown',
  label: 'Card',
  pattern: /^/,
  lengths: [13, 14, 15, 16, 17, 18, 19],
  groups: [4, 4, 4, 4, 3],
  cvcLength: 3,
};

const digitsOnly = (input: string): string => {
  return input.replace(/\D/g, '');
};

export const getCardBrandSpec = (brand: CardBrand): CardBrandSpec => {
  return CARD_BRANDS.find((b) => b.brand === brand) ?? UNKNOWN_BRAND;
};

/** Detect the card brand from the leading digits. Non-digits are ignored. */
export const detectCardBrand = (number: string): CardBrand => {
  const digits = digitsOnly(number);
  if (!digits) return 'unknown';

  return CARD_BRANDS.find((b) => b.pattern.test(digits))?.brand ?? 'unknown';
};

/** Luhn checksum. Returns false for anything shorter than 12 digits. */
export const luhnCheck = (number: string): boolean => {
  const digits = digitsOnly(number);
  if (digits.length < 12) return false;
  let sum = 0;
  let double = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let n = Number(digits[i]);
    if (double) {
      n *= 2;
      if (n > 9) n -= 9;
    }
    sum += n;
    double = !double;
  }

  return sum % 10 === 0;
};

/** Insert spaces per the brand's digit groups. Trims to the longest length. */
export const formatCardNumber = (number: string, brand?: CardBrand): string => {
  const spec = getCardBrandSpec(brand ?? detectCardBrand(number));
  const maxLength = Math.max(...spec.lengths);
  const digits = digitsOnly(number).slice(0, maxLength);
  const parts: string[] = [];
  let i = 0;
  for (const size of spec.groups) {
    if (i >= digits.length) break;
    parts.push(digits.slice(i, i + size));
    i += size;
  }
  if (i < digits.length) parts.push(digits.slice(i));

  return parts.join(' ');
};

/** Digits in `formatted` before index `position`. */
const digitsBefore = (formatted: string, position: number): number => {
  return digitsOnly(formatted.slice(0, position)).length;
};

/** Index in `formatted` just after its `count`th digit. */
const positionAfterDigits = (formatted: string, count: number): number => {
  if (count <= 0) return 0;
  let seen = 0;
  for (let i = 0; i < formatted.length; i++) {
    if (/\d/.test(formatted[i]) && ++seen === count) return i + 1;
  }

  return formatted.length;
};

export const formatCardExpiry = (expiry: string): string => {
  let digits = digitsOnly(expiry);
  // Autofill can write MM/YYYY; keep the two-digit year instead of the century.
  if (digits.length === 6 && digits.slice(2, 4) === '20') digits = digits.slice(0, 2) + digits.slice(4);
  digits = digits.slice(0, 4);
  if (digits.length <= 2) return digits;

  return `${digits.slice(0, 2)}/${digits.slice(2)}`;
};

const isExpiryValid = (expiry: string, now: Date): boolean => {
  const digits = digitsOnly(expiry);
  if (digits.length !== 4) return false;
  const month = Number(digits.slice(0, 2));
  const year = 2000 + Number(digits.slice(2));
  if (month < 1 || month > 12) return false;
  const nowYear = now.getFullYear();
  const nowMonth = now.getMonth() + 1;
  if (year < nowYear) return false;
  if (year === nowYear && month < nowMonth) return false;

  return year - nowYear <= 20;
};

export type CreditCardField = 'number' | 'expiry' | 'cvc';

export interface CreditCardValue {
  /** Digits only, no spaces. */
  number: string;
  /** `MM/YY`, or a partial string while typing. */
  expiry: string;
  cvc: string;
}

export interface CreditCardChange extends CreditCardValue {
  brand: CardBrand;
  valid: boolean;
  /** Fields that fail validation right now. */
  errors: CreditCardField[];
}

type CreditCardInputVariant = 'row' | 'stack';

interface CreditCardInputContextValue {
  id: string;
  value: CreditCardValue;
  brand: CardBrand;
  spec: CardBrandSpec;
  errors: CreditCardField[];
  touched: Record<CreditCardField, boolean>;
  touch: (field: CreditCardField) => void;
  setField: (field: CreditCardField, next: string) => void;
  focusField: (field: CreditCardField) => void;
  variant: CreditCardInputVariant;
  disabled?: boolean;
}

const CreditCardInputContext = React.createContext<CreditCardInputContextValue | null>(null);

const useCreditCardInput = () => {
  const ctx = React.useContext(CreditCardInputContext);
  if (!ctx) {
    throw new Error('CreditCardInput compound parts must be used inside <CreditCardInput>');
  }

  return ctx;
};

const EMPTY_VALUE: CreditCardValue = { number: '', expiry: '', cvc: '' };

export interface CreditCardInputProps extends Omit<React.ComponentProps<'div'>, 'onChange' | 'defaultValue'> {
  id?: string;
  value?: CreditCardValue;
  defaultValue?: CreditCardValue;
  onValueChange?: (value: CreditCardChange) => void;
  /** `row` is one bordered strip; `stack` leaves each field on its own. */
  variant?: CreditCardInputVariant;
  disabled?: boolean;
}

const computeErrors = (value: CreditCardValue, spec: CardBrandSpec, now: Date): CreditCardField[] => {
  const errors: CreditCardField[] = [];
  const numberOk = spec.lengths.includes(value.number.length) && luhnCheck(value.number);
  if (!numberOk) errors.push('number');
  if (!isExpiryValid(value.expiry, now)) errors.push('expiry');
  if (digitsOnly(value.cvc).length !== spec.cvcLength) errors.push('cvc');

  return errors;
};

const CreditCardInput = ({
  id,
  value: valueProp,
  defaultValue,
  onValueChange,
  variant = 'row',
  disabled,
  className,
  children,
  ...props
}: CreditCardInputProps) => {
  const reactId = React.useId();
  const rootId = id ?? reactId;
  const [internal, setInternal] = React.useState<CreditCardValue>(defaultValue ?? EMPTY_VALUE);
  const value = valueProp ?? internal;
  const [touched, setTouched] = React.useState<Record<CreditCardField, boolean>>({
    number: false,
    expiry: false,
    cvc: false,
  });

  const brand = React.useMemo(() => detectCardBrand(value.number), [value.number]);
  const spec = React.useMemo(() => getCardBrandSpec(brand), [brand]);
  const errors = React.useMemo(() => computeErrors(value, spec, new Date()), [value, spec]);

  const focusField = React.useCallback(
    (field: CreditCardField) => {
      const el = document.getElementById(`${rootId}-${field}`) as HTMLInputElement | null;
      el?.focus();
      el?.select();
    },
    [rootId],
  );

  const setField = React.useCallback(
    (field: CreditCardField, raw: string) => {
      const next: CreditCardValue = { ...value };
      if (field === 'number') next.number = digitsOnly(raw);
      else if (field === 'expiry') next.expiry = formatCardExpiry(raw);
      else next.cvc = digitsOnly(raw);
      // Brand patterns match on the prefix only, so detecting before truncation is safe.
      const nextBrand = detectCardBrand(next.number);
      const nextSpec = getCardBrandSpec(nextBrand);
      next.number = next.number.slice(0, Math.max(...nextSpec.lengths));
      // A brand switch (Amex 4 -> Visa 3) shortens the CVC in state, not just on screen.
      next.cvc = next.cvc.slice(0, nextSpec.cvcLength);
      if (valueProp === undefined) setInternal(next);
      const nextErrors = computeErrors(next, nextSpec, new Date());
      onValueChange?.({
        ...next,
        brand: nextBrand,
        valid: nextErrors.length === 0,
        errors: nextErrors,
      });
    },
    [value, valueProp, onValueChange],
  );

  const touch = React.useCallback((field: CreditCardField) => {
    setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
  }, []);

  const ctx = React.useMemo<CreditCardInputContextValue>(
    () => ({
      id: rootId,
      value,
      brand,
      spec,
      errors,
      touched,
      touch,
      setField,
      focusField,
      variant,
      disabled,
    }),
    [rootId, value, brand, spec, errors, touched, touch, setField, focusField, variant, disabled],
  );

  return (
    <CreditCardInputContext.Provider value={ctx}>
      <div
        data-slot="credit-card-input"
        data-variant={variant}
        data-brand={brand}
        data-disabled={disabled || undefined}
        className={cn(
          variant === 'row'
            ? cn(
                'flex w-full items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30',
                'has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50',
                'has-[input[aria-invalid=true]]:border-destructive',
              )
            : 'grid w-full gap-3',
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </CreditCardInputContext.Provider>
  );
};

const ROW_INPUT =
  'h-10 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:border-transparent dark:bg-transparent aria-invalid:ring-0';

type FieldProps = Omit<React.ComponentProps<typeof Input>, 'value' | 'defaultValue' | 'onChange' | 'type' | 'id'>;

// Backspace in an empty field steps back to `previous`.
const useFieldProps = (
  field: CreditCardField,
  { onBlur, onKeyDown }: Pick<FieldProps, 'onBlur' | 'onKeyDown'>,
  previous?: CreditCardField,
) => {
  const ctx = useCreditCardInput();

  return {
    id: `${ctx.id}-${field}`,
    inputMode: 'numeric' as const,
    dir: 'ltr',
    disabled: ctx.disabled,
    'aria-invalid': (ctx.touched[field] && ctx.errors.includes(field)) || undefined,
    onBlur: (e: React.FocusEvent<HTMLInputElement>) => {
      ctx.touch(field);
      onBlur?.(e);
    },
    onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (previous && e.key === 'Backspace' && ctx.value[field] === '') ctx.focusField(previous);
      onKeyDown?.(e);
    },
  };
};

interface CreditCardInputNumberProps extends FieldProps {
  /** Rendered at the end of the field. Defaults to `<CreditCardInputBrand />`. */
  children?: React.ReactNode;
}

const CreditCardInputNumber = ({
  placeholder = '1234 5678 9012 3456',
  className,
  children,
  onBlur,
  onKeyDown,
  ...props
}: CreditCardInputNumberProps) => {
  const ctx = useCreditCardInput();
  const inputRef = React.useRef<HTMLInputElement>(null);
  // Digits left of the caret, restored after the reformat; a controlled rewrite otherwise drops the caret at the end.
  const caretRef = React.useRef<number | null>(null);
  const fieldProps = useFieldProps('number', {
    onBlur,
    onKeyDown: (e) => {
      const el = e.currentTarget;
      const start = el.selectionStart ?? 0;
      const collapsed = start === el.selectionEnd;
      // Deleting a group space would change nothing, so delete the digit beyond it instead.
      const overSpace =
        collapsed &&
        ((e.key === 'Backspace' && el.value[start - 1] === ' ') || (e.key === 'Delete' && el.value[start] === ' '));
      if (overSpace) {
        e.preventDefault();
        const digits = digitsBefore(el.value, start);
        const removeAt = e.key === 'Backspace' ? digits - 1 : digits;
        caretRef.current = removeAt;
        ctx.setField('number', ctx.value.number.slice(0, removeAt) + ctx.value.number.slice(removeAt + 1));
      }
      onKeyDown?.(e);
    },
  });
  const formatted = formatCardNumber(ctx.value.number, ctx.brand);

  React.useLayoutEffect(() => {
    const el = inputRef.current;
    const digits = caretRef.current;
    caretRef.current = null;
    if (!el || digits === null || document.activeElement !== el) return;
    const position = positionAfterDigits(formatted, digits);
    el.setSelectionRange(position, position);
  });

  return (
    <div
      data-slot="credit-card-input-number"
      className={cn('relative min-w-0', ctx.variant === 'row' ? 'flex-1' : undefined)}
    >
      <Input
        {...fieldProps}
        autoComplete="cc-number"
        placeholder={placeholder}
        value={formatted}
        aria-label="Card number"
        data-slot="credit-card-input-number-field"
        className={cn('pe-16 tracking-[0.06em] tabular-nums', ctx.variant === 'row' && ROW_INPUT, className)}
        onChange={(e) => {
          const { value } = e.target;
          const caret = digitsBefore(value, e.target.selectionStart ?? value.length);
          const total = digitsOnly(value).length;
          caretRef.current = caret;
          ctx.setField('number', value);
          // Only advance when typing at the end, not while fixing a digit in the middle.
          if (ctx.spec.lengths.length === 1 && total >= ctx.spec.lengths[0] && caret >= total) {
            ctx.focusField('expiry');
          }
        }}
        {...props}
        ref={composeRefs(inputRef, props.ref)}
      />
      <span className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2">
        {children ?? <CreditCardInputBrand />}
      </span>
    </div>
  );
};

const CreditCardInputExpiry = ({ placeholder = 'MM/YY', className, onBlur, onKeyDown, ...props }: FieldProps) => {
  const ctx = useCreditCardInput();
  const fieldProps = useFieldProps('expiry', { onBlur, onKeyDown }, 'number');

  return (
    <Input
      {...fieldProps}
      autoComplete="cc-exp"
      placeholder={placeholder}
      value={ctx.value.expiry}
      aria-label="Expiry date"
      data-slot="credit-card-input-expiry"
      className={cn('tabular-nums', ctx.variant === 'row' && cn(ROW_INPUT, 'w-20 border-s border-input'), className)}
      onChange={(e) => {
        ctx.setField('expiry', e.target.value);
        if (digitsOnly(e.target.value).length >= 4) ctx.focusField('cvc');
      }}
      {...props}
    />
  );
};

const CreditCardInputCvc = ({ placeholder = 'CVC', className, onBlur, onKeyDown, ...props }: FieldProps) => {
  const ctx = useCreditCardInput();
  const fieldProps = useFieldProps('cvc', { onBlur, onKeyDown }, 'expiry');

  return (
    <Input
      {...fieldProps}
      autoComplete="cc-csc"
      placeholder={placeholder}
      maxLength={ctx.spec.cvcLength}
      value={ctx.value.cvc}
      aria-label="Security code"
      data-slot="credit-card-input-cvc"
      className={cn('tabular-nums', ctx.variant === 'row' && cn(ROW_INPUT, 'w-16 border-s border-input'), className)}
      onChange={(e) => ctx.setField('cvc', e.target.value)}
      {...props}
    />
  );
};

interface CreditCardInputBrandProps extends Omit<React.ComponentProps<'span'>, 'children'> {
  labels?: Partial<Record<CardBrand, string>>;
}

const CreditCardInputBrand = ({ labels, className, ...props }: CreditCardInputBrandProps) => {
  const ctx = useCreditCardInput();
  const label = labels?.[ctx.brand] ?? ctx.spec.label;

  return (
    <span
      data-slot="credit-card-input-brand"
      data-brand={ctx.brand}
      aria-live="polite"
      className={cn(
        'inline-flex h-5 items-center justify-center rounded-sm border border-border bg-muted px-1.5 text-xs font-medium text-muted-foreground uppercase',
        ctx.brand !== 'unknown' && 'text-foreground',
        className,
      )}
      {...props}
    >
      {ctx.brand === 'unknown' ? <CreditCard className="size-3.5" aria-label={label} /> : label}
    </span>
  );
};

export { CreditCardInput, CreditCardInputNumber, CreditCardInputExpiry, CreditCardInputCvc, CreditCardInputBrand };

Dependencies

shadcn registry

inputcompose-refs

npm

lucide-react

Shipped