Masked Input
Pattern-masked inputs and localized currency formatting with stable caret positioning.
@drift-ui/masked-inputRegistry JSONRaw value: 4242424242424242
Cents: 4999
On this page
Installation
The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.
Usage
import { CurrencyInput, MaskedInput } from "@/components/ui/masked-input"
import { Label } from "@/components/ui/label"
export function PaymentFields() {
return (
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="card">Card number</Label>
<MaskedInput id="card" mask="#### #### #### ####" placeholder="4242 4242 4242 4242" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="price">Amount</Label>
<CurrencyInput id="price" currency="USD" locale="en-US" defaultValue={4999} />
</div>
</div>
)
}Examples
Currency formatting
Localized currencies format symbols and decimal separators automatically while storing integer cents.
API reference
MaskedInput
Input that formats typed characters according to a mask pattern while storing the raw unmasked value.
| Prop | Type | Default |
|---|---|---|
maskRequiredstringMask pattern where # matches digits, a or A matches letters, and * matches alphanumeric characters. | string | |
definitionsRecord<string, RegExp>Custom character token definitions mapping token character to matching RegExp pattern. | Record<string, RegExp> | |
valuestringControlled unmasked raw string. | string | |
defaultValuestringDefault""Initial value when uncontrolled. | string | "" |
onValueChange(value: string, formattedValue: string) => voidCalled with the unmasked and formatted values. | (value: string, formattedValue: string) => void | |
namestringSubmits the raw unmasked string via a hidden input. | string | |
disabledbooleanDisables the input. | boolean |
Other props go to the <input>.
CurrencyInput
Input that formats monetary amounts according to currency and locale while storing integer cents.
| Prop | Type | Default |
|---|---|---|
currencystringDefault"USD"ISO 4217 currency code (e.g. USD, EUR, JPY, GBP). | string | "USD" |
localestringDefault"en-US"BCP 47 language tag for number and currency formatting. | string | "en-US" |
valuenumber | nullControlled amount in integer cents. | number | null | |
defaultValuenumber | nullDefaultnullInitial value when uncontrolled. | number | null | null |
onValueChange(value: number | null, formattedValue: string) => voidCalled with integer cents and formatted text. | (value: number | null, formattedValue: string) => void | |
minnumberMinimum allowed amount in cents. Enforced on blur. | number | |
maxnumberMaximum allowed amount in cents. Enforced during typing and on blur. | number | |
namestringSubmits the integer cents via a hidden input. | string | |
disabledbooleanDisables the input. | boolean |
Other props go to the <input>.
Source
"use client"
import * as React from "react"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
applyMask,
calculateCurrencyCaretPosition,
calculateMaskCaretPosition,
DEFAULT_MASK_DEFINITIONS,
deleteCurrencyDigitPastFormatting,
deleteTokenPastFormatting,
formatCurrency,
formatCurrencyLive,
parseCurrency,
unmaskValue,
} from "@/lib/masked-input"
import { cn } from "@/lib/utils"
interface MaskedInputProps extends Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange"
> {
mask: string
value?: string
defaultValue?: string
onValueChange?: (value: string, formattedValue: string) => void
definitions?: Record<string, RegExp>
}
function MaskedInput({
mask,
value,
defaultValue = "",
onValueChange,
definitions = DEFAULT_MASK_DEFINITIONS,
name,
disabled,
className,
ref,
...props
}: MaskedInputProps) {
const { value: rawValue, setValue: setRawValue } = useControllableState({
value,
defaultValue,
onChange: (val) => {
const { formatted } = applyMask(val, mask, definitions)
onValueChange?.(val, formatted)
},
})
const inputRef = React.useRef<HTMLInputElement | null>(null)
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
const [edit, setEdit] = React.useState<{
text: string
caret: number
} | null>(null)
const formattedValue = React.useMemo(
() => applyMask(rawValue ?? "", mask, definitions).formatted,
[rawValue, mask, definitions]
)
const display =
edit !== null &&
unmaskValue(edit.text, mask, definitions) === (rawValue ?? "")
? edit.text
: formattedValue
React.useLayoutEffect(() => {
const input = inputRef.current
if (input && document.activeElement === input && edit !== null) {
input.setSelectionRange(edit.caret, edit.caret)
}
}, [edit])
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { value: rawInput, selectionStart } = event.target
const { inputType } = (event.nativeEvent as InputEvent) || {}
const isForward = inputType === "deleteContentForward"
const { text, caret } = deleteTokenPastFormatting(
display,
rawInput,
selectionStart ?? rawInput.length,
isForward,
mask,
definitions
)
const unmasked = unmaskValue(text, mask, definitions)
const { formatted: nextFormatted, raw: nextRaw } = applyMask(
unmasked,
mask,
definitions
)
const nextCursor = calculateMaskCaretPosition({
nextFormatted,
rawInput: text,
rawCursorPos: caret,
mask,
definitions,
})
setEdit({ text: nextFormatted, caret: nextCursor })
setRawValue(nextRaw)
}
return (
<>
<input
{...props}
ref={inputRef}
type="text"
data-slot="masked-input"
disabled={disabled}
value={display}
onChange={handleChange}
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
/>
{name && (
<input
type="hidden"
name={name}
disabled={disabled}
value={rawValue ?? ""}
/>
)}
</>
)
}
interface CurrencyInputProps extends Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange"
> {
currency?: string
locale?: string
value?: number | null
defaultValue?: number | null
onValueChange?: (value: number | null, formattedValue: string) => void
min?: number
max?: number
}
function CurrencyInput({
currency = "USD",
locale = "en-US",
value,
defaultValue = null,
onValueChange,
min,
max,
name,
disabled,
className,
onBlur,
ref,
...props
}: CurrencyInputProps) {
const { value: centsValue, setValue: setCentsValue } = useControllableState<
number | null
>({
value,
defaultValue,
onChange: (val) => {
const formatted = formatCurrency(val, locale, currency)
onValueChange?.(val, formatted)
},
})
const inputRef = React.useRef<HTMLInputElement | null>(null)
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
const [edit, setEdit] = React.useState<{
text: string
caret: number
} | null>(null)
const display =
edit !== null && parseCurrency(edit.text, locale, currency) === centsValue
? edit.text
: centsValue !== null && centsValue !== undefined
? formatCurrency(centsValue, locale, currency)
: ""
React.useLayoutEffect(() => {
const input = inputRef.current
if (input && document.activeElement === input && edit !== null) {
input.setSelectionRange(edit.caret, edit.caret)
}
}, [edit])
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { value: rawInput, selectionStart } = event.target
const { inputType } = (event.nativeEvent as InputEvent) || {}
const isForward = inputType === "deleteContentForward"
const { text, caret } = deleteCurrencyDigitPastFormatting(
display,
rawInput,
selectionStart ?? rawInput.length,
isForward,
locale
)
if (!text.trim()) {
setEdit(null)
setCentsValue(null)
return
}
const { formatted, cents } = formatCurrencyLive(text, locale, currency)
let finalCents = cents
let finalFormatted = formatted
if (finalCents !== null && max !== undefined && finalCents > max) {
finalCents = max
finalFormatted = formatCurrency(max, locale, currency)
}
const nextCursor = calculateCurrencyCaretPosition({
nextFormatted: finalFormatted,
rawInput: text,
rawCursorPos: caret,
locale,
currency,
})
setEdit({ text: finalFormatted, caret: nextCursor })
setCentsValue(finalCents)
}
function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
setEdit(null)
if (centsValue !== null) {
const clamped = Math.min(
max ?? Infinity,
Math.max(min ?? -Infinity, centsValue)
)
if (clamped !== centsValue) {
setCentsValue(clamped)
}
}
onBlur?.(event)
}
return (
<>
<input
{...props}
ref={inputRef}
type="text"
inputMode="decimal"
data-slot="currency-input"
disabled={disabled}
value={display}
onChange={handleChange}
onBlur={handleBlur}
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
/>
{name && (
<input
type="hidden"
name={name}
disabled={disabled}
value={
centsValue !== null && centsValue !== undefined ? centsValue : ""
}
/>
)}
</>
)
}
export {
MaskedInput,
CurrencyInput,
type MaskedInputProps,
type CurrencyInputProps,
}