shadimage
persianlabsui
uibank-inputsource: ok · hit

Bank Input

Iranian card-number and Shaba inputs with paste normalization, checksum validation, and bank detection.

npx shadcn add "https://ui.persian-labs.ir/r/bank-input.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@persianlabs/icons

Registry dependencies

utils@persianlabsui/input-group@persianlabsui/iranian-bank

Files2 · source cached 10/8/2026, 10:24:26 PM

registry/base/ui/bank-input.tsx
"use client"

import * as React from "react"
import * as BankIcons from "@persianlabs/icons/react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
  formatCardNumber,
  formatShaba,
  getIranianBankByCardNumber,
  getIranianBankByShaba,
  normalizeCardNumber,
  normalizeShaba,
  type IranianBank,
  type IranianBankId,
} from "@/lib/iranian-bank"
import { cn } from "@/lib/utils"

export type BankLogoVariant = "color" | "mono" | false

type BaseBankInputProps = Omit<
  React.ComponentProps<typeof InputGroupInput>,
  "value" | "defaultValue" | "onChange" | "type" | "dir"
> & {
  /** Renders a color or monochrome logo once a bank is detected. @default "color" */
  bankLogo?: BankLogoVariant
  /** Receives the detected bank as soon as enough digits are entered. */
  onBankChange?: (bank: IranianBank | undefined) => void
  /** The separator inserted after each four-digit group. */
  separator?: string
}

export interface CardNumberInputProps extends BaseBankInputProps {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
}

export interface ShabaInputProps extends BaseBankInputProps {
  /** The account portion only, without the visible IR prefix. */
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
}

const iconNames: Record<IranianBankId, string> = {
  blubank: "BankBlubank",
  dey: "BankDey",
  eghtesad_novin: "BankEghtesadNovin",
  gardeshgari: "BankGardeshgari",
  ghavvamin: "BankGhavamin",
  hekmat: "BankHekmat",
  iranzamin: "BankIranZamin",
  kar_afarin: "BankKarafarin",
  keshavarzi: "BankKeshavarzi",
  khavarmianeh: "BankKhavarMianeh",
  maskan: "BankMaskan",
  mehr_e_iranian: "BankMehrIran",
  meli: "BankMelall",
  mellat: "BankMellat",
  melli: "BankMelli",
  parsian: "BankParsian",
  pasargad: "BankPasargad",
  post_bank: "BankPostbank",
  refah: "BankRefah",
  resalat: "BankResalat",
  saderat: "BankSaderat",
  saman: "BankSaman",
  sarmayeh: "BankSarmayeh",
  sepah: "BankSepah",
  shahr: "BankShahr",
  sina: "BankSina",
  tejarat: "BankTejarat",
  tosee_saderat: "BankToseeSaderat",
  tosee_taavon: "BankToseeTaavon",
}

function offsetAfterDigits(value: string, digitCount: number) {
  if (digitCount <= 0) return 0
  let seen = 0
  for (let index = 0; index < value.length; index++) {
    if (/\d/.test(value[index]!)) seen++
    if (seen === digitCount) return index + 1
  }
  return value.length
}

function restoreCaret(
  input: HTMLInputElement,
  formatted: string,
  digitsBeforeCaret: number
) {
  requestAnimationFrame(() => {
    const offset = offsetAfterDigits(formatted, digitsBeforeCaret)
    input.setSelectionRange(offset, offset)
  })
}

function BankIdentity({
  bank,
  logo,
}: {
  bank: IranianBank | undefined
  logo: BankLogoVariant
}) {
  if (!bank) return null
  const Logo = BankIcons[
    `${iconNames[bank.id]}${logo === "mono" ? "Mono" : "Color"}` as keyof typeof BankIcons
  ] as React.ComponentType<React.SVGProps<SVGSVGElement>>

  return (
    <InputGroupAddon align="inline-end" className="pe-2">
      <Logo className="size-5 shrink-0" aria-hidden="true" />
    </InputGroupAddon>
  )
}

function CardNumberInput({
  value,
  defaultValue = "",
  onValueChange,
  bankLogo = "color",
  onBankChange,
  separator = " ",
  className,
  ...props
}: CardNumberInputProps) {
  const [number, setNumber] = useControllableState({
    prop: value,
    defaultProp: defaultValue,
    onChange: onValueChange,
    caller: "CardNumberInput",
  })
  const normalized = normalizeCardNumber(number)
  const bank = getIranianBankByCardNumber(normalized)

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const digitsBeforeCaret = normalizeCardNumber(
      event.target.value.slice(0, event.target.selectionStart ?? 0)
    ).length
    const next = normalizeCardNumber(event.target.value)
    setNumber(next)
    onBankChange?.(getIranianBankByCardNumber(next))
    restoreCaret(
      event.target,
      formatCardNumber(next, separator),
      digitsBeforeCaret
    )
  }

  return (
    <InputGroup className={cn("h-10", className)} dir="ltr">
      <InputGroupInput
        {...props}
        dir="ltr"
        inputMode="numeric"
        autoComplete="cc-number"
        spellCheck={false}
        translate="no"
        value={formatCardNumber(normalized, separator)}
        onChange={handleChange}
        placeholder={`1234${separator}1234${separator}1234${separator}1234`}
        className="font-mono text-sm tracking-wide md:text-base"
      />
      {bankLogo && <BankIdentity bank={bank} logo={bankLogo} />}
    </InputGroup>
  )
}

function ShabaInput({
  value,
  defaultValue = "",
  onValueChange,
  bankLogo = "color",
  onBankChange,
  separator = " ",
  className,
  ...props
}: ShabaInputProps) {
  const [account, setAccount] = useControllableState({
    prop: value,
    defaultProp: defaultValue,
    onChange: onValueChange,
    caller: "ShabaInput",
  })
  const normalized = normalizeShaba(account)
  const bank = getIranianBankByShaba(normalized)

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const digitsBeforeCaret = normalizeShaba(
      event.target.value.slice(0, event.target.selectionStart ?? 0)
    ).length
    const next = normalizeShaba(event.target.value)
    setAccount(next)
    onBankChange?.(getIranianBankByShaba(next))
    restoreCaret(event.target, formatShaba(next, separator), digitsBeforeCaret)
  }

  return (
    <InputGroup className={cn("h-10", className)} dir="ltr">
      <InputGroupAddon align="inline-start">
        <InputGroupText className="font-mono font-semibold tracking-wide">
          IR
        </InputGroupText>
      </InputGroupAddon>
      <InputGroupInput
        {...props}
        dir="ltr"
        inputMode="numeric"
        autoComplete="off"
        spellCheck={false}
        translate="no"
        value={formatShaba(normalized, separator)}
        onChange={handleChange}
        placeholder={`1234${separator}1234${separator}1234${separator}1234${separator}1234${separator}1234`}
        className="font-mono text-sm tracking-wide md:text-base"
      />
      {bankLogo && <BankIdentity bank={bank} logo={bankLogo} />}
    </InputGroup>
  )
}

export { CardNumberInput, ShabaInput }
registry/base/hooks/use-controllable-state.ts
// Thanks @radix-ui

import * as React from "react"

// use-layout-effect.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-layout-effect/src/use-layout-effect.tsx

/**
 * On the server, React emits a warning when calling `useLayoutEffect`.
 * This is because neither `useLayoutEffect` nor `useEffect` run on the server.
 * We use this safe version which suppresses the warning by replacing it with a noop on the server.
 *
 * See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
 */
const useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {}

// use-controllable-state.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx

// Prevent bundlers from trying to optimize the import
const useInsertionEffect: typeof useLayoutEffect =
  (React as never)[" useInsertionEffect ".trim().toString()] || useLayoutEffect

type ChangeHandler<T> = (state: T) => void
type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>

interface UseControllableStateParams<T> {
  prop?: T | undefined
  defaultProp: T
  onChange?: ChangeHandler<T>
  caller?: string
}

export function useControllableState<T>({
  prop,
  defaultProp,
  onChange = () => {},
  caller,
}: UseControllableStateParams<T>): [T, SetStateFn<T>] {
  const [uncontrolledProp, setUncontrolledProp, onChangeRef] =
    useUncontrolledState({
      defaultProp,
      onChange,
    })
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolledProp

  // OK to disable conditionally calling hooks here because they will always run
  // consistently in the same environment. Bundlers should be able to remove the
  // code block entirely in production.
  /* eslint-disable react-hooks/rules-of-hooks */
  if (process.env.NODE_ENV !== "production") {
    const isControlledRef = React.useRef(prop !== undefined)
    React.useEffect(() => {
      const wasControlled = isControlledRef.current
      if (wasControlled !== isControlled) {
        const from = wasControlled ? "controlled" : "uncontrolled"
        const to = isControlled ? "controlled" : "uncontrolled"
        console.warn(
          `${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`
        )
      }
      isControlledRef.current = isControlled
    }, [isControlled, caller])
  }
  /* eslint-enable react-hooks/rules-of-hooks */

  const setValue = React.useCallback<SetStateFn<T>>(
    (nextValue) => {
      if (isControlled) {
        const value = isFunction(nextValue) ? nextValue(prop) : nextValue
        if (value !== prop) {
          onChangeRef.current?.(value)
        }
      } else {
        setUncontrolledProp(nextValue)
      }
    },
    [isControlled, prop, setUncontrolledProp, onChangeRef]
  )

  return [value, setValue]
}

function useUncontrolledState<T>({
  defaultProp,
  onChange,
}: Omit<UseControllableStateParams<T>, "prop">): [
  Value: T,
  setValue: React.Dispatch<React.SetStateAction<T>>,
  OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,
] {
  const [value, setValue] = React.useState(defaultProp)
  const prevValueRef = React.useRef(value)

  const onChangeRef = React.useRef(onChange)
  useInsertionEffect(() => {
    onChangeRef.current = onChange
  }, [onChange])

  React.useEffect(() => {
    if (prevValueRef.current !== value) {
      onChangeRef.current?.(value)
      prevValueRef.current = value
    }
  }, [value, prevValueRef])

  return [value, setValue, onChangeRef]
}

function isFunction(value: unknown): value is (...args: never[]) => unknown {
  return typeof value === "function"
}

More from persianlabsui

Browse all components in this library →