Masked Input

Pattern-masked inputs and localized currency formatting with stable caret positioning.

@drift-ui/masked-inputRegistry JSON

Raw 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

masked-input-usage.tsx
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

Card formatting

Formats credit card and expiration inputs into standardized patterns.

Currency formatting

Localized currencies format symbols and decimal separators automatically while storing integer cents.

Phone numbers

Fixed patterns guide users into typing phone numbers in familiar formats.

Digits: none

API reference

MaskedInput

Input that formats typed characters according to a mask pattern while storing the raw unmasked value.

Prop
maskRequiredstringMask pattern where # matches digits, a or A matches letters, and * matches alphanumeric characters.
definitionsRecord<string, RegExp>Custom character token definitions mapping token character to matching RegExp pattern.
valuestringControlled unmasked raw string.
defaultValuestringDefault""Initial value when uncontrolled.
onValueChange(value: string, formattedValue: string) => voidCalled with the unmasked and formatted values.
namestringSubmits the raw unmasked string via a hidden input.
disabledbooleanDisables the input.

Other props go to the <input>.

CurrencyInput

Input that formats monetary amounts according to currency and locale while storing integer cents.

Prop
currencystringDefault"USD"ISO 4217 currency code (e.g. USD, EUR, JPY, GBP).
localestringDefault"en-US"BCP 47 language tag for number and currency formatting.
valuenumber | nullControlled amount in integer cents.
defaultValuenumber | nullDefaultnullInitial value when uncontrolled.
onValueChange(value: number | null, formattedValue: string) => voidCalled with integer cents and formatted text.
minnumberMinimum allowed amount in cents. Enforced on blur.
maxnumberMaximum allowed amount in cents. Enforced during typing and on blur.
namestringSubmits the integer cents via a hidden input.
disabledbooleanDisables the input.

Other props go to the <input>.

Source

components/ui/masked-input.tsx
"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,
}