Skip to content
6

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.

"row" | "stack"'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';

/* -------------------------------------------------------------------------- */
/*  Brand detection + helpers                                                 */
/* -------------------------------------------------------------------------- */

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]|2(2[2-9]|[3-6]|7[01]|720))/,
    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(' ');
};

export const formatCardExpiry = (expiry: string): string => {
  const digits = digitsOnly(expiry).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;
};

/* -------------------------------------------------------------------------- */
/*  Types + context                                                           */
/* -------------------------------------------------------------------------- */

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[];
}

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;
  register: (field: CreditCardField, el: HTMLInputElement | null) => void;
  variant: 'row' | 'stack';
  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;
};

/* -------------------------------------------------------------------------- */
/*  Root                                                                      */
/* -------------------------------------------------------------------------- */

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?: 'row' | 'stack';
  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 inputs = React.useRef<Record<CreditCardField, HTMLInputElement | null>>({
    number: null,
    expiry: null,
    cvc: null,
  });

  const register = React.useCallback((field: CreditCardField, el: HTMLInputElement | null) => {
    inputs.current[field] = el;
  }, []);

  const focusField = React.useCallback((field: CreditCardField) => {
    const el = inputs.current[field];
    if (el) {
      el.focus();
      el.select();
    }
  }, []);

  const setField = React.useCallback(
    (field: CreditCardField, raw: string) => {
      const next: CreditCardValue = { ...value };
      if (field === 'number') {
        const nextBrand = detectCardBrand(raw);
        const max = Math.max(...getCardBrandSpec(nextBrand).lengths);
        next.number = digitsOnly(raw).slice(0, max);
      } else if (field === 'expiry') {
        next.expiry = formatCardExpiry(raw);
      } else {
        next.cvc = digitsOnly(raw).slice(0, 4);
      }
      if (valueProp === undefined) setInternal(next);
      const nextBrand = detectCardBrand(next.number);
      const nextSpec = getCardBrandSpec(nextBrand);
      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,
      register,
      variant,
      disabled,
    }),
    [rootId, value, brand, spec, errors, touched, touch, setField, focusField, register, 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',
          disabled && 'opacity-60',
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </CreditCardInputContext.Provider>
  );
};

/* -------------------------------------------------------------------------- */
/*  Fields                                                                    */
/* -------------------------------------------------------------------------- */

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'>;

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 invalid = ctx.touched.number && ctx.errors.includes('number');
  const maxLength = Math.max(...ctx.spec.lengths);
  return (
    <div
      data-slot="credit-card-input-number"
      className={cn('relative min-w-0', ctx.variant === 'row' ? 'flex-1' : undefined)}
    >
      <Input
        ref={(el) => ctx.register('number', el)}
        id={`${ctx.id}-number`}
        type="text"
        inputMode="numeric"
        autoComplete="cc-number"
        dir="ltr"
        placeholder={placeholder}
        value={formatCardNumber(ctx.value.number, ctx.brand)}
        disabled={ctx.disabled}
        aria-invalid={invalid || undefined}
        aria-label="Card number"
        data-slot="credit-card-input-number-field"
        className={cn('pe-16 font-mono tracking-[0.06em]', ctx.variant === 'row' && ROW_INPUT, className)}
        onChange={(e) => {
          ctx.setField('number', e.target.value);
          if (digitsOnly(e.target.value).length >= maxLength && ctx.spec.lengths.length === 1) {
            ctx.focusField('expiry');
          }
        }}
        onBlur={(e) => {
          ctx.touch('number');
          onBlur?.(e);
        }}
        onKeyDown={onKeyDown}
        {...props}
      />
      <span className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2">
        {children ?? <CreditCardInputBrand />}
      </span>
    </div>
  );
};

type CreditCardInputExpiryProps = FieldProps;

const CreditCardInputExpiry = ({
  placeholder = 'MM/YY',
  className,
  onBlur,
  onKeyDown,
  ...props
}: CreditCardInputExpiryProps) => {
  const ctx = useCreditCardInput();
  const invalid = ctx.touched.expiry && ctx.errors.includes('expiry');
  return (
    <Input
      ref={(el) => ctx.register('expiry', el)}
      id={`${ctx.id}-expiry`}
      type="text"
      inputMode="numeric"
      autoComplete="cc-exp"
      dir="ltr"
      placeholder={placeholder}
      value={ctx.value.expiry}
      disabled={ctx.disabled}
      aria-invalid={invalid || undefined}
      aria-label="Expiry date"
      data-slot="credit-card-input-expiry"
      className={cn('font-mono', 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');
      }}
      onKeyDown={(e) => {
        if (e.key === 'Backspace' && ctx.value.expiry === '') {
          ctx.focusField('number');
        }
        onKeyDown?.(e);
      }}
      onBlur={(e) => {
        ctx.touch('expiry');
        onBlur?.(e);
      }}
      {...props}
    />
  );
};

type CreditCardInputCvcProps = FieldProps;

const CreditCardInputCvc = ({
  placeholder = 'CVC',
  className,
  onBlur,
  onKeyDown,
  ...props
}: CreditCardInputCvcProps) => {
  const ctx = useCreditCardInput();
  const invalid = ctx.touched.cvc && ctx.errors.includes('cvc');
  return (
    <Input
      ref={(el) => ctx.register('cvc', el)}
      id={`${ctx.id}-cvc`}
      type="text"
      inputMode="numeric"
      autoComplete="cc-csc"
      dir="ltr"
      placeholder={placeholder}
      maxLength={ctx.spec.cvcLength}
      value={ctx.value.cvc.slice(0, ctx.spec.cvcLength)}
      disabled={ctx.disabled}
      aria-invalid={invalid || undefined}
      aria-label="Security code"
      data-slot="credit-card-input-cvc"
      className={cn('font-mono', ctx.variant === 'row' && cn(ROW_INPUT, 'w-16 border-s border-input'), className)}
      onChange={(e) => ctx.setField('cvc', e.target.value)}
      onKeyDown={(e) => {
        if (e.key === 'Backspace' && ctx.value.cvc === '') {
          ctx.focusField('expiry');
        }
        onKeyDown?.(e);
      }}
      onBlur={(e) => {
        ctx.touch('cvc');
        onBlur?.(e);
      }}
      {...props}
    />
  );
};

/* -------------------------------------------------------------------------- */
/*  Brand badge                                                               */
/* -------------------------------------------------------------------------- */

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 font-mono text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground',
        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

input

npm

lucide-react

Shipped