Masked Input
masked-inputuiInput with a fixed format mask — phone numbers, credit cards, dates, postal codes. Pass a mask string (e.g. `(###) ###-####`) and the input enforces it as the user types. Customizable placeholder character and replacement marker.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/masked-input.json$npx shadcn@latest add https://uipkge.dev/r/react/masked-input.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/masked-input.json$bunx shadcn@latest add https://uipkge.dev/r/react/masked-input.jsonnpx shadcn@latest add @uipkge-react/masked-inputInstalls to:components/ui/masked-input/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueControlled masked value (e.g. `(212) 555-____`). | string | — | optional |
defaultValue | string | — | optional |
maskMask template — `replacement` chars are editable, everything else is literal. | string | — | required |
replacementThe character in `mask` that marks an editable slot. | string | — | optional |
tokensCustom token regex mapping for editable slots. Defaults: `#` -> digits, `A` -> letters, `*` -> alphanumeric. | MaskTokens | — | optional |
placeholderCharCharacter shown for unfilled editable slots when `showMask` is on. | string | — | optional |
showMaskRender the literal mask (separators + placeholders) even when empty. | boolean | — | optional |
invalidInvalid visual state. | boolean | — | optional |
errorError message or error boolean flag. | string | boolean | — | optional |
errorMessageExplicit error message string. | string | — | optional |
validateCustom validation function. | (masked: string, raw: string) => boolean | string | — | optional |
onValueChangeFires with the masked string on every edit. | (value: string) => void | — | optional |
onCompleteFires with the masked string once every editable slot is filled. | (value: string) => void | — | optional |
onValidateFires validation status payload on edit. | (payload: { isValid: boolean; isComplete: boolean; rawValue: string; maskedValue: string }) => void | — | optional |
className | string | — | optional |
Files installed (2)
components/ui/masked-input/masked-input.tsx14.2 kB
'use client' import * as React from 'react' import { cn } from '@/lib/utils' export type MaskTokens = Record<string, RegExp> const DEFAULT_TOKENS: MaskTokens = { '#': /^[0-9]$/, A: /^[a-zA-Z]$/, '*': /^[a-zA-Z0-9]$/, } export interface MaskedInputProps extends Omit< React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' > { /** Controlled masked value (e.g. `(212) 555-____`). */ value?: string defaultValue?: string /** Mask template — `replacement` chars are editable, everything else is literal. */ mask: string /** The character in `mask` that marks an editable slot. */ replacement?: string /** Custom token regex mapping for editable slots. Defaults: `#` -> digits, `A` -> letters, `*` -> alphanumeric. */ tokens?: MaskTokens /** Character shown for unfilled editable slots when `showMask` is on. */ placeholderChar?: string /** Render the literal mask (separators + placeholders) even when empty. */ showMask?: boolean /** Invalid visual state. */ invalid?: boolean /** Error message or error boolean flag. */ error?: string | boolean /** Explicit error message string. */ errorMessage?: string /** Custom validation function. */ validate?: (masked: string, raw: string) => boolean | string /** Fires with the masked string on every edit. */ onValueChange?: (value: string) => void /** Fires with the masked string once every editable slot is filled. */ onComplete?: (value: string) => void /** Fires validation status payload on edit. */ onValidate?: (payload: { isValid: boolean; isComplete: boolean; rawValue: string; maskedValue: string }) => void className?: string } const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>( ( { value, defaultValue, mask, replacement = '#', tokens, placeholderChar = '_', placeholder, showMask = true, invalid, error, errorMessage, validate, onValueChange, onComplete, onValidate, disabled, readOnly, className, onFocus, onBlur, onKeyDown, onPaste, ...rest }, ref, ) => { const innerRef = React.useRef<HTMLInputElement | null>(null) const setRefs = React.useCallback( (node: HTMLInputElement | null) => { innerRef.current = node if (typeof ref === 'function') ref(node) else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node }, [ref], ) const isControlled = value !== undefined const [internal, setInternal] = React.useState<string>(defaultValue ?? '') const [isFocused, setIsFocused] = React.useState(false) const modelValue = isControlled ? value : internal const activeTokens = React.useMemo<MaskTokens>( () => ({ ...DEFAULT_TOKENS, ...(tokens ?? {}), }), [tokens], ) const editableSlots = React.useMemo(() => { const slots: Array<{ index: number; tokenChar: string }> = [] for (let i = 0; i < mask.length; i++) { const char = mask[i]! if (char === replacement || activeTokens[char]) { slots.push({ index: i, tokenChar: char === replacement ? replacement : char }) } } return slots }, [mask, replacement, activeTokens]) const editablePositions = React.useMemo(() => editableSlots.map((s) => s.index), [editableSlots]) const maxLength = editablePositions.length const getSlotPattern = React.useCallback( (slotIndex: number): RegExp => { const slot = editableSlots[slotIndex] if (!slot) return /.*/ return activeTokens[slot.tokenChar] ?? activeTokens[replacement] ?? /.*/ }, [editableSlots, activeTokens, replacement], ) const isValidCharForSlot = React.useCallback( (char: string, slotIndex: number): boolean => { const regex = getSlotPattern(slotIndex) return regex.test(char) }, [getSlotPattern], ) const unmask = React.useCallback( (val: string): string => { let result = '' let slotIndex = 0 for (const char of val) { if (char === placeholderChar || char === ' ') continue if (slotIndex < maxLength && isValidCharForSlot(char, slotIndex)) { result += char slotIndex++ } } return result }, [placeholderChar, maxLength, isValidCharForSlot], ) const applyMask = React.useCallback( (rawValue: string): string => { let result = '' let rawIndex = 0 for (let i = 0; i < mask.length; i++) { const isEditable = editablePositions.includes(i) if (isEditable) { const slotIdx = editablePositions.indexOf(i) if (rawIndex < rawValue.length && isValidCharForSlot(rawValue[rawIndex]!, slotIdx)) { result += rawValue[rawIndex] rawIndex++ } else if (showMask && (isFocused || !placeholder || modelValue)) { result += placeholderChar } else { break } } else { result += mask[i] } } return result }, [mask, editablePositions, isValidCharForSlot, showMask, isFocused, placeholder, modelValue, placeholderChar], ) const isComplete = React.useMemo(() => { const raw = unmask(modelValue ?? '') return raw.length === maxLength }, [modelValue, unmask, maxLength]) const validationError = React.useMemo(() => { if (!validate) return null const current = modelValue ?? '' const raw = unmask(current) const res = validate(current, raw) if (typeof res === 'string') return res if (res === false) return 'Invalid format' return null }, [validate, modelValue, unmask]) const isInvalid = Boolean( invalid || error === true || (typeof error === 'string' && error.length > 0) || validationError, ) const displayErrorMessage = typeof error === 'string' && error.length > 0 ? error : errorMessage || validationError React.useEffect(() => { if (isComplete) onComplete?.(modelValue ?? '') }, [isComplete, modelValue, onComplete]) React.useEffect(() => { const raw = unmask(modelValue ?? '') onValidate?.({ isValid: !isInvalid, isComplete, rawValue: raw, maskedValue: modelValue ?? '', }) }, [modelValue, isInvalid, isComplete, unmask, onValidate]) const getNextEditablePos = React.useCallback( (currentPos: number): number => { for (const pos of editablePositions) { if (pos >= currentPos) return pos } return editablePositions[editablePositions.length - 1] ?? mask.length }, [editablePositions, mask.length], ) const getPrevEditablePos = React.useCallback( (currentPos: number): number => { for (let i = editablePositions.length - 1; i >= 0; i--) { const p = editablePositions[i] if (p !== undefined && p < currentPos) return p } return editablePositions[0] ?? 0 }, [editablePositions], ) const findRawIndexAtCursor = React.useCallback( (cursorPos: number): number => { let rawIndex = 0 for (let i = 0; i < cursorPos && i < mask.length; i++) { if (editablePositions.includes(i)) rawIndex++ } return rawIndex }, [mask.length, editablePositions], ) const findCursorPosFromRaw = React.useCallback( (rawIndex: number): number => { if (rawIndex >= editablePositions.length) return mask.length return editablePositions[rawIndex] ?? mask.length }, [editablePositions, mask.length], ) const updateValue = React.useCallback( (next: string) => { if (!isControlled) setInternal(next) onValueChange?.(next) }, [isControlled, onValueChange], ) const handleInput = (event: React.ChangeEvent<HTMLInputElement>) => { const target = event.target const oldValue = modelValue ?? '' const newValue = target.value const cursorPos = target.selectionStart ?? 0 const rawNew = unmask(newValue) const rawOld = unmask(oldValue) const clampedRaw = rawNew.slice(0, maxLength) const masked = applyMask(clampedRaw) let newCursorPos: number if (clampedRaw.length > rawOld.length) { const addedIndex = clampedRaw.length - 1 newCursorPos = findCursorPosFromRaw(addedIndex) + 1 newCursorPos = getNextEditablePos(newCursorPos) } else if (clampedRaw.length < rawOld.length) { newCursorPos = getPrevEditablePos(cursorPos) + 1 } else { newCursorPos = cursorPos } updateValue(masked) requestAnimationFrame(() => { if (innerRef.current) { innerRef.current.setSelectionRange(newCursorPos, newCursorPos) } }) } const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { onKeyDown?.(event) if (event.defaultPrevented) return const target = event.currentTarget const cursorPos = target.selectionStart ?? 0 // Block non-matching character immediately on keystroke if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { const rawIndex = findRawIndexAtCursor(cursorPos) if (rawIndex >= maxLength || !isValidCharForSlot(event.key, rawIndex)) { event.preventDefault() return } } if (event.key === 'Backspace') { const raw = unmask(modelValue ?? '') const rawIndex = findRawIndexAtCursor(cursorPos) if (rawIndex > 0) { const newRaw = raw.slice(0, rawIndex - 1) + raw.slice(rawIndex) const masked = applyMask(newRaw) updateValue(masked) const newPos = findCursorPosFromRaw(rawIndex - 1) requestAnimationFrame(() => { innerRef.current?.setSelectionRange(newPos, newPos) }) } event.preventDefault() } else if (event.key === 'Delete') { const raw = unmask(modelValue ?? '') const rawIndex = findRawIndexAtCursor(cursorPos) if (rawIndex < raw.length) { const newRaw = raw.slice(0, rawIndex) + raw.slice(rawIndex + 1) const masked = applyMask(newRaw) updateValue(masked) const newPos = findCursorPosFromRaw(rawIndex) requestAnimationFrame(() => { innerRef.current?.setSelectionRange(newPos, newPos) }) } event.preventDefault() } else if (event.key === 'ArrowLeft') { const newPos = getPrevEditablePos(cursorPos) requestAnimationFrame(() => { innerRef.current?.setSelectionRange(newPos, newPos) }) event.preventDefault() } else if (event.key === 'ArrowRight') { const newPos = getNextEditablePos(cursorPos + 1) requestAnimationFrame(() => { innerRef.current?.setSelectionRange(newPos, newPos) }) event.preventDefault() } } const handlePasteEvent = (event: React.ClipboardEvent<HTMLInputElement>) => { onPaste?.(event) if (event.defaultPrevented) return event.preventDefault() const pasted = event.clipboardData?.getData('text') ?? '' const raw = unmask(modelValue ?? '') const cursorPos = innerRef.current?.selectionStart ?? 0 const rawIndex = findRawIndexAtCursor(cursorPos) let filteredPasted = '' let currSlot = rawIndex for (const char of pasted) { if (currSlot < maxLength && isValidCharForSlot(char, currSlot)) { filteredPasted += char currSlot++ } } const newRaw = (raw.slice(0, rawIndex) + filteredPasted + raw.slice(rawIndex)).slice(0, maxLength) const masked = applyMask(newRaw) updateValue(masked) const newPos = findCursorPosFromRaw(Math.min(rawIndex + filteredPasted.length, maxLength)) requestAnimationFrame(() => { innerRef.current?.setSelectionRange(newPos, newPos) }) } const handleFocusEvent = (e: React.FocusEvent<HTMLInputElement>) => { setIsFocused(true) if (!modelValue && showMask) { updateValue(applyMask('')) } requestAnimationFrame(() => { const firstEditable = editablePositions[0] ?? 0 innerRef.current?.setSelectionRange(firstEditable, firstEditable) }) onFocus?.(e) } const handleBlurEvent = (e: React.FocusEvent<HTMLInputElement>) => { setIsFocused(false) onBlur?.(e) } const displayValue = !modelValue && !isFocused && placeholder ? '' : !modelValue && !showMask ? '' : (modelValue ?? '') return ( <div className="relative w-full" data-slot="masked-input-wrapper"> <input ref={setRefs} value={displayValue} placeholder={placeholder} data-uipkge data-slot="masked-input" disabled={disabled} readOnly={readOnly} aria-invalid={isInvalid ? 'true' : undefined} className={cn( 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', isInvalid && 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 text-destructive', className, )} onChange={handleInput} onKeyDown={handleKeyDown} onFocus={handleFocusEvent} onBlur={handleBlurEvent} onPaste={handlePasteEvent} {...rest} /> {displayErrorMessage && ( <p data-slot="masked-input-error" className="text-destructive mt-1.5 text-xs font-medium" role="alert"> {displayErrorMessage} </p> )} </div> ) }, ) MaskedInput.displayName = 'MaskedInput' export { MaskedInput }components/ui/masked-input/index.ts0.1 kB
export { MaskedInput, type MaskedInputProps } from './masked-input'
Raw manifest:https://uipkge.dev/r/react/masked-input.json