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"
}