Dice UI
Components

Mask Input

An input component that formats user input with predefined patterns like phone numbers, dates, and credit cards.

"use client";
 
import * as React from "react";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
 
interface Input {
  phone: string;
  date: string;
  dollar: string;
  euro: string;
  creditCard: string;
  percentage: string;
}
 
interface Validation {
  phone: boolean;
  date: boolean;
  dollar: boolean;
  euro: boolean;
  creditCard: boolean;
  percentage: boolean;
}
 
export function MaskInputDemo() {
  const id = React.useId();
  const [input, setInput] = React.useState<Input>({
    phone: "",
    date: "",
    dollar: "",
    euro: "",
    creditCard: "",
    percentage: "",
  });
  const [validation, setValidation] = React.useState<Validation>({
    phone: true,
    date: true,
    dollar: true,
    euro: true,
    creditCard: true,
    percentage: true,
  });
 
  const onValueChange = React.useCallback(
    (field: keyof Input) => (maskedValue: string) => {
      setInput((prev) => ({
        ...prev,
        [field]: maskedValue,
      }));
    },
    [],
  );
 
  const onValidate = React.useCallback(
    (field: keyof Validation) => (isValid: boolean) => {
      setValidation((prev) => ({
        ...prev,
        [field]: isValid,
      }));
    },
    [],
  );
 
  return (
    <div className="grid w-full gap-6 md:grid-cols-2 lg:grid-cols-3">
      <div className="flex flex-col gap-2">
        <Label htmlFor={`${id}-phone`}>Phone Number</Label>
        <MaskInput
          id={`${id}-phone`}
          mask="phone"
          placeholder="Enter your phone number"
          value={input.phone}
          onValueChange={onValueChange("phone")}
          onValidate={onValidate("phone")}
          invalid={!validation.phone}
        />
        <p className="text-muted-foreground text-sm">
          Enter your phone number with area code
        </p>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor={`${id}-date`}>Birth Date</Label>
        <MaskInput
          id={`${id}-date`}
          mask="date"
          placeholder="mm/dd/yyyy"
          value={input.date}
          onValueChange={onValueChange("date")}
          onValidate={onValidate("date")}
          invalid={!validation.date}
        />
        <p className="text-muted-foreground text-sm">Enter your birth date</p>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor={`${id}-dollar`}>Dollar</Label>
        <MaskInput
          id={`${id}-dollar`}
          mask="currency"
          placeholder="$0.00"
          value={input.dollar}
          onValueChange={onValueChange("dollar")}
          onValidate={onValidate("dollar")}
          invalid={!validation.dollar}
        />
        <p className="text-muted-foreground text-sm">Enter your currency</p>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor={`${id}-euro`}>Euro (German)</Label>
        <MaskInput
          id={`${id}-euro`}
          mask="currency"
          currency="EUR"
          locale="de-DE"
          placeholder="0,00 €"
          value={input.euro}
          onValueChange={onValueChange("euro")}
          onValidate={onValidate("euro")}
          invalid={!validation.euro}
        />
        <p className="text-muted-foreground text-sm">Enter your currency</p>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor={`${id}-creditCard`}>Credit Card</Label>
        <MaskInput
          id={`${id}-creditCard`}
          mask="creditCard"
          placeholder="4242 4242 4242 4242"
          value={input.creditCard}
          onValueChange={onValueChange("creditCard")}
          onValidate={onValidate("creditCard")}
          invalid={!validation.creditCard}
        />
        <p className="text-muted-foreground text-sm">
          Enter your credit card number
        </p>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor={`${id}-percentage`}>Percentage (0-100%)</Label>
        <MaskInput
          id={`${id}-percentage`}
          mask="percentage"
          placeholder="0.00%"
          min={0}
          max={100}
          value={input.percentage}
          onValueChange={onValueChange("percentage")}
          onValidate={onValidate("percentage")}
          invalid={!validation.percentage}
        />
        <p className="text-muted-foreground text-sm">
          Enter a percentage between 0% and 100%
        </p>
      </div>
    </div>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/mask-input"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
 
const PAST_YEARS_LIMIT = 120;
const FUTURE_YEARS_LIMIT = 10;
const DEFAULT_CURRENCY = "USD";
const DEFAULT_LOCALE = "en-US";
 
const NUMERIC_MASK_PATTERNS =
  /^(phone|zipCode|zipCodeExtended|ssn|ein|time|date|creditCard)$/;
const CURRENCY_PERCENTAGE_SYMBOLS = /[€$%]/;
const CURRENCY_FALLBACK = "$0.00";
const ZERO_PERCENTAGE = "0.00%";
 
interface CurrencySymbols {
  currency: string;
  decimal: string;
  group: string;
}
 
const formattersCache = new Map<string, Intl.NumberFormat>();
const currencyAtEndCache = new Map<string, boolean>();
const currencySymbolsCache = new Map<string, CurrencySymbols>();
const daysInMonthCache = [
  31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
] as const;
 
const REGEX_CACHE = {
  digitsOnly: /^\d+$/,
  nonDigits: /\D/g,
  nonAlphaNumeric: /[^A-Z0-9]/gi,
  nonNumericDot: /[^0-9.]/g,
  nonCurrencyChars: /[^\d.,]/g,
  hashPattern: /#/g,
  currencyAtEnd: /\d\s*[^\d\s]+$/,
  percentageChars: /[^\d.]/g,
  phone: /^\d{10}$/,
  ssn: /^\d{9}$/,
  zipCode: /^\d{5}$/,
  zipCodeExtended: /^\d{9}$/,
  isbn: /^\d{13}$/,
  ein: /^\d{9}$/,
  time: /^\d{4}$/,
  creditCard: /^\d{15,19}$/,
  licensePlate: /^[A-Z0-9]{6}$/,
  macAddress: /^[A-F0-9]{12}$/,
  currencyValidation: /^\d+(\.\d{1,2})?$/,
  ipv4Segment: /^\d{1,3}$/,
} as const;
 
function getCachedFormatter(
  locale: string,
  opts: Intl.NumberFormatOptions,
): Intl.NumberFormat {
  const {
    currency,
    minimumFractionDigits = 0,
    maximumFractionDigits = 2,
  } = opts;
 
  const key = `${locale}|${currency}|${minimumFractionDigits}|${maximumFractionDigits}`;
 
  if (!formattersCache.has(key)) {
    try {
      formattersCache.set(
        key,
        new Intl.NumberFormat(locale, {
          style: "currency",
          currency,
          ...opts,
        }),
      );
    } catch {
      formattersCache.set(
        key,
        new Intl.NumberFormat(DEFAULT_LOCALE, {
          style: "currency",
          currency: DEFAULT_CURRENCY,
          ...opts,
        }),
      );
    }
  }
  const formatter = formattersCache.get(key);
  if (!formatter) {
    throw new Error(`Failed to create formatter for ${key}`);
  }
  return formatter;
}
 
function getCachedCurrencySymbols(opts: {
  locale: string;
  currency: string;
}): CurrencySymbols {
  const { locale, currency } = opts;
 
  const key = `${locale}|${currency}`;
  const cached = currencySymbolsCache.get(key);
  if (cached) {
    return cached;
  }
 
  let currencySymbol = "$";
  let decimalSeparator = ".";
  let groupSeparator = ",";
 
  try {
    const formatter = getCachedFormatter(locale, {
      currency,
      minimumFractionDigits: 0,
      maximumFractionDigits: 2,
    });
    const parts = formatter.formatToParts(1234.5);
    const currencyPart = parts.find((part) => part.type === "currency");
    const decimalPart = parts.find((part) => part.type === "decimal");
    const groupPart = parts.find((part) => part.type === "group");
 
    if (currencyPart) currencySymbol = currencyPart.value;
    if (decimalPart) decimalSeparator = decimalPart.value;
    if (groupPart) groupSeparator = groupPart.value;
  } catch {
    // Keep defaults
  }
 
  const symbols: CurrencySymbols = {
    currency: currencySymbol,
    decimal: decimalSeparator,
    group: groupSeparator,
  };
  currencySymbolsCache.set(key, symbols);
  return symbols;
}
 
function isCurrencyAtEnd(opts: { locale: string; currency: string }): boolean {
  const { locale, currency } = opts;
 
  const key = `${locale}|${currency}`;
  const cached = currencyAtEndCache.get(key);
  if (cached !== undefined) {
    return cached;
  }
 
  try {
    const formatter = getCachedFormatter(locale, {
      currency,
      minimumFractionDigits: 0,
      maximumFractionDigits: 0,
    });
    const sample = formatter.format(123);
    const result = REGEX_CACHE.currencyAtEnd.test(sample);
    currencyAtEndCache.set(key, result);
    return result;
  } catch {
    currencyAtEndCache.set(key, false);
    return false;
  }
}
 
function isCurrencyMask(opts: {
  mask: MaskPatternKey | MaskPattern | undefined;
  pattern?: string;
}): boolean {
  const { mask, pattern } = opts;
 
  return (
    mask === "currency" ||
    Boolean(pattern && (pattern.includes("$") || pattern.includes("€")))
  );
}
 
interface TransformOptions {
  currency?: string;
  locale?: string;
}
 
interface ValidateOptions {
  min?: number;
  max?: number;
}
 
interface MaskPattern {
  pattern: string;
  placeholder?: string;
  transform?: (value: string, opts?: TransformOptions) => string;
  validate?: (value: string, opts?: ValidateOptions) => boolean;
}
 
type MaskPatternKey =
  | "phone"
  | "ssn"
  | "date"
  | "time"
  | "creditCard"
  | "zipCode"
  | "zipCodeExtended"
  | "currency"
  | "percentage"
  | "licensePlate"
  | "ipv4"
  | "macAddress"
  | "isbn"
  | "ein";
 
const MASK_PATTERNS: Record<MaskPatternKey, MaskPattern> = {
  phone: {
    pattern: "(###) ###-####",
    placeholder: "(___) ___-____",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.phone.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  ssn: {
    pattern: "###-##-####",
    placeholder: "___-__-____",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.ssn.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  date: {
    pattern: "##/##/####",
    placeholder: "mm/dd/yyyy",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) => {
      const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
      if (cleaned.length !== 8) return false;
      const month = parseInt(cleaned.substring(0, 2), 10);
      const day = parseInt(cleaned.substring(2, 4), 10);
      const year = parseInt(cleaned.substring(4, 8), 10);
 
      const currentYear = new Date().getFullYear();
      const minYear = currentYear - PAST_YEARS_LIMIT;
      const maxYear = currentYear + FUTURE_YEARS_LIMIT;
      if (
        month < 1 ||
        month > 12 ||
        day < 1 ||
        year < minYear ||
        year > maxYear
      )
        return false;
 
      const maxDays =
        month === 2 &&
        ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0)
          ? 29
          : (daysInMonthCache[month - 1] ?? 31);
 
      return day <= maxDays;
    },
  },
  time: {
    pattern: "##:##",
    placeholder: "hh:mm",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) => {
      const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
      if (!REGEX_CACHE.time.test(cleaned)) return false;
      const hours = parseInt(cleaned.substring(0, 2), 10);
      const minutes = parseInt(cleaned.substring(2, 4), 10);
      return hours <= 23 && minutes <= 59;
    },
  },
  creditCard: {
    pattern: "#### #### #### ####",
    placeholder: "____ ____ ____ ____",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) => {
      const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
      return REGEX_CACHE.creditCard.test(cleaned);
    },
  },
  zipCode: {
    pattern: "#####",
    placeholder: "_____",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.zipCode.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  zipCodeExtended: {
    pattern: "#####-####",
    placeholder: "_____-____",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.zipCodeExtended.test(
        value.replace(REGEX_CACHE.nonDigits, ""),
      ),
  },
  currency: {
    pattern: "$###,###.##",
    placeholder: "$0.00",
    transform: (
      value,
      { currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = {},
    ) => {
      let localeDecimalSeparator = ".";
 
      try {
        const formatter = getCachedFormatter(locale, {
          currency,
          minimumFractionDigits: 0,
          maximumFractionDigits: 2,
        });
        const parts = formatter.formatToParts(1234.5);
        const decimalPart = parts.find((part) => part.type === "decimal");
 
        if (decimalPart) localeDecimalSeparator = decimalPart.value;
      } catch {
        // Keep defaults
      }
 
      const cleaned = value.replace(REGEX_CACHE.nonCurrencyChars, "");
 
      const dotIndex = cleaned.indexOf(".");
      const commaIndex = cleaned.indexOf(",");
 
      let hasDecimalSeparator = false;
      let decimalIndex = -1;
 
      if (localeDecimalSeparator === ",") {
        const lastCommaIndex = cleaned.lastIndexOf(",");
        if (lastCommaIndex !== -1) {
          const afterComma = cleaned.substring(lastCommaIndex + 1);
          if (afterComma.length <= 2 && /^\d*$/.test(afterComma)) {
            hasDecimalSeparator = true;
            decimalIndex = lastCommaIndex;
          }
        }
 
        if (!hasDecimalSeparator && dotIndex !== -1) {
          const afterDot = cleaned.substring(dotIndex + 1);
          if (afterDot.length <= 2 && /^\d*$/.test(afterDot)) {
            hasDecimalSeparator = true;
            decimalIndex = dotIndex;
          }
        }
 
        if (!hasDecimalSeparator && cleaned.length >= 4) {
          const match = cleaned.match(/^(\d+)\.(\d{3})(\d{1,2})$/);
          if (match) {
            const [, beforeDot, thousandsPart, decimalPart] = match;
            const integerPart = (beforeDot || "") + (thousandsPart || "");
            const result = `${integerPart}.${decimalPart}`;
            return result;
          }
        }
      } else {
        const lastDotIndex = cleaned.lastIndexOf(".");
        if (lastDotIndex !== -1) {
          const afterDot = cleaned.substring(lastDotIndex + 1);
          if (afterDot.length <= 2 && /^\d*$/.test(afterDot)) {
            hasDecimalSeparator = true;
            decimalIndex = lastDotIndex;
          }
        }
 
        if (!hasDecimalSeparator && commaIndex !== -1) {
          const afterComma = cleaned.substring(commaIndex + 1);
          const looksLikeThousands = commaIndex <= 3 && afterComma.length >= 3;
          if (
            !looksLikeThousands &&
            afterComma.length <= 2 &&
            /^\d*$/.test(afterComma)
          ) {
            hasDecimalSeparator = true;
            decimalIndex = commaIndex;
          }
        }
      }
 
      if (hasDecimalSeparator && decimalIndex !== -1) {
        const beforeDecimal = cleaned
          .substring(0, decimalIndex)
          .replace(/[.,]/g, "");
        const afterDecimal = cleaned
          .substring(decimalIndex + 1)
          .replace(/[.,]/g, "");
 
        if (afterDecimal === "") {
          const result = `${beforeDecimal}.`;
          return result;
        }
 
        const result = `${beforeDecimal}.${afterDecimal.substring(0, 2)}`;
        return result;
      }
 
      const digitsOnly = cleaned.replace(/[.,]/g, "");
      return digitsOnly;
    },
    validate: (value) => {
      if (!REGEX_CACHE.currencyValidation.test(value)) return false;
      const num = parseFloat(value);
      return !Number.isNaN(num) && num >= 0;
    },
  },
  percentage: {
    pattern: "##.##%",
    placeholder: "0.00%",
    transform: (value) => {
      const cleaned = value.replace(REGEX_CACHE.percentageChars, "");
      const parts = cleaned.split(".");
      if (parts.length > 2) {
        return `${parts[0]}.${parts.slice(1).join("")}`;
      }
      if (parts[1] && parts[1].length > 2) {
        return `${parts[0]}.${parts[1].substring(0, 2)}`;
      }
      return cleaned;
    },
    validate: (value, opts = {}) => {
      const num = parseFloat(value);
      const min = opts.min ?? 0;
      const max = opts.max ?? 100;
      return !Number.isNaN(num) && num >= min && num <= max;
    },
  },
  licensePlate: {
    pattern: "###-###",
    placeholder: "ABC-123",
    transform: (value) =>
      value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(),
    validate: (value) => REGEX_CACHE.licensePlate.test(value),
  },
  ipv4: {
    pattern: "###.###.###.###",
    placeholder: "192.168.1.1",
    transform: (value) => value.replace(REGEX_CACHE.nonNumericDot, ""),
    validate: (value) => {
      if (value.includes(".")) {
        const segments = value.split(".");
        if (segments.length > 4) return false;
 
        return segments.every((segment) => {
          if (segment === "") return true;
          if (!REGEX_CACHE.ipv4Segment.test(segment)) return false;
          const num = parseInt(segment, 10);
          return num <= 255;
        });
      } else {
        if (!REGEX_CACHE.digitsOnly.test(value)) return false;
        if (value.length > 12) return false;
 
        const chunks = [];
        for (let i = 0; i < value.length; i += 3) {
          chunks.push(value.substring(i, i + 3));
        }
 
        if (chunks.length > 4) return false;
 
        return chunks.every((chunk) => {
          const num = parseInt(chunk, 10);
          return num >= 0 && num <= 255;
        });
      }
    },
  },
  macAddress: {
    pattern: "##:##:##:##:##:##",
    placeholder: "00:1B:44:11:3A:B7",
    transform: (value) =>
      value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(),
    validate: (value) => REGEX_CACHE.macAddress.test(value),
  },
  isbn: {
    pattern: "###-#-###-#####-#",
    placeholder: "978-0-123-45678-9",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.isbn.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  ein: {
    pattern: "##-#######",
    placeholder: "12-3456789",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.ein.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
};
 
function applyMask(opts: {
  value: string;
  pattern: string;
  currency?: string;
  locale?: string;
  mask?: MaskPatternKey | MaskPattern;
}): string {
  const { value, pattern, currency, locale, mask } = opts;
 
  const cleanValue = value;
 
  if (pattern.includes("$") || pattern.includes("€") || mask === "currency") {
    return applyCurrencyMask({
      value: cleanValue,
      currency: currency ?? DEFAULT_CURRENCY,
      locale: locale ?? DEFAULT_LOCALE,
    });
  }
 
  if (pattern.includes("%")) {
    return applyPercentageMask(cleanValue);
  }
 
  if (mask === "ipv4") {
    return cleanValue;
  }
 
  const maskedChars: string[] = [];
  let valueIndex = 0;
 
  for (let i = 0; i < pattern.length && valueIndex < cleanValue.length; i++) {
    const patternChar = pattern[i];
    const valueChar = cleanValue[valueIndex];
 
    if (patternChar === "#" && valueChar) {
      maskedChars.push(valueChar);
      valueIndex++;
    } else if (patternChar) {
      maskedChars.push(patternChar);
    }
  }
 
  return maskedChars.join("");
}
 
function applyCurrencyMask(opts: {
  value: string;
  currency?: string;
  locale?: string;
}): string {
  const { value, currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = opts;
 
  if (!value) return "";
 
  const {
    currency: currencySymbol,
    decimal: decimalSeparator,
    group: groupSeparator,
  } = getCachedCurrencySymbols({ locale, currency });
 
  const normalizedValue = value
    .replace(
      new RegExp(
        `\\${groupSeparator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
        "g",
      ),
      "",
    )
    .replace(decimalSeparator, ".");
 
  const parts = normalizedValue.split(".");
  const integerPart = parts[0] ?? "";
  const fractionalPart = parts[1] ?? "";
 
  if (!integerPart && !fractionalPart) return "";
 
  const intValue = integerPart ?? "0";
  const fracValue = fractionalPart.slice(0, 2);
 
  const num = Number(`${intValue}.${fracValue ?? ""}`);
 
  if (Number.isNaN(num)) {
    const cleanedDigits = value.replace(/[^\d]/g, "");
    if (!cleanedDigits) return "";
    return `${currencySymbol}${cleanedDigits}`;
  }
 
  const hasExplicitDecimal =
    value.includes(".") || value.includes(decimalSeparator);
 
  try {
    const formatter = getCachedFormatter(locale, {
      currency,
      minimumFractionDigits: fracValue ? fracValue.length : 0,
      maximumFractionDigits: 2,
    });
    const result = formatter.format(num);
 
    if (hasExplicitDecimal && !fracValue) {
      if (result.match(/^[^\d\s]+/)) {
        const finalResult = result.replace(/(\d)$/, `$1${decimalSeparator}`);
        return finalResult;
      } else {
        const finalResult = result.replace(
          /(\d)(\s*)([^\d\s]+)$/,
          `$1${decimalSeparator}$2$3`,
        );
        return finalResult;
      }
    }
 
    return result;
  } catch {
    const formattedInt = intValue.replace(
      /\B(?=(\d{3})+(?!\d))/g,
      groupSeparator,
    );
    let result = `${currencySymbol}${formattedInt}`;
    if (hasExplicitDecimal) {
      result += `${decimalSeparator}${fracValue}`;
    }
 
    return result;
  }
}
 
function applyPercentageMask(value: string): string {
  if (!value) return "";
 
  const parts = value.split(".");
  let result = parts[0] ?? "0";
 
  if (value.includes(".")) {
    result += `.${(parts[1] ?? "").substring(0, 2)}`;
  }
 
  return `${result}%`;
}
 
function getUnmaskedValue(opts: {
  value: string;
  currency?: string;
  locale?: string;
  transform?: (value: string, opts?: TransformOptions) => string;
}): string {
  const { value, transform, currency, locale } = opts;
 
  return transform
    ? transform(value, { currency, locale })
    : value.replace(REGEX_CACHE.nonDigits, "");
}
 
function toUnmaskedIndex(opts: {
  masked: string;
  pattern: string;
  caret: number;
}): number {
  const { masked, pattern, caret } = opts;
 
  let idx = 0;
  for (let i = 0; i < caret && i < masked.length && i < pattern.length; i++) {
    if (pattern[i] === "#") {
      idx++;
    }
  }
  return idx;
}
 
function fromUnmaskedIndex(opts: {
  masked: string;
  pattern: string;
  unmaskedIndex: number;
}): number {
  const { masked, pattern, unmaskedIndex } = opts;
 
  let seen = 0;
  for (let i = 0; i < masked.length && i < pattern.length; i++) {
    if (pattern[i] === "#") {
      seen++;
      if (seen === unmaskedIndex) {
        return i + 1;
      }
    }
  }
  return masked.length;
}
 
type InputElement = React.ComponentRef<"input">;
 
interface MaskInputProps extends React.ComponentProps<"input"> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (maskedValue: string, unmaskedValue: string) => void;
  onValidate?: (isValid: boolean, unmaskedValue: string) => void;
  validationMode?: "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all";
  mask?: MaskPatternKey | MaskPattern;
  currency?: string;
  locale?: string;
  asChild?: boolean;
  invalid?: boolean;
  withoutMask?: boolean;
}
 
function getCurrencyCaretPosition(
  newValue: string,
  mask: MaskPatternKey | MaskPattern | undefined,
  transformOpts: { currency: string; locale: string },
): number {
  if (mask === "currency") {
    const currencyAtEnd = isCurrencyAtEnd(transformOpts);
    if (currencyAtEnd) {
      const match = newValue.match(/(\d)\s*([^\d\s]+)$/);
      if (match?.[1]) {
        return newValue.lastIndexOf(match[1]) + 1;
      } else {
        return newValue.length;
      }
    } else {
      return newValue.length;
    }
  } else {
    return newValue.length;
  }
}
 
function getPatternCaretPosition(
  newValue: string,
  maskPattern: MaskPattern,
  currentUnmasked: string,
): number {
  let position = 0;
  let unmaskedCount = 0;
 
  for (let i = 0; i < maskPattern.pattern.length && i < newValue.length; i++) {
    if (maskPattern.pattern[i] === "#") {
      unmaskedCount++;
      if (unmaskedCount <= currentUnmasked.length) {
        position = i + 1;
      }
    }
  }
  return position;
}
 
function MaskInput(props: MaskInputProps) {
  const {
    value: valueProp,
    defaultValue,
    onValueChange: onValueChangeProp,
    onValidate,
    onBlur: onBlurProp,
    onFocus: onFocusProp,
    onKeyDown: onKeyDownProp,
    onPaste: onPasteProp,
    onCompositionStart: onCompositionStartProp,
    onCompositionEnd: onCompositionEndProp,
    validationMode = "onChange",
    mask,
    currency = DEFAULT_CURRENCY,
    locale = DEFAULT_LOCALE,
    placeholder,
    inputMode,
    min,
    max,
    maxLength,
    asChild = false,
    disabled = false,
    invalid = false,
    readOnly = false,
    required = false,
    withoutMask = false,
    className,
    ref,
    ...inputProps
  } = props;
 
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
  const [focused, setFocused] = React.useState(false);
  const [composing, setComposing] = React.useState(false);
  const [touched, setTouched] = React.useState(false);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const composedRef = useComposedRefs(ref, inputRef);
 
  const isControlled = valueProp !== undefined;
  const value = isControlled ? valueProp : internalValue;
 
  const maskPattern = React.useMemo(() => {
    if (typeof mask === "string") {
      return MASK_PATTERNS[mask];
    }
    return mask;
  }, [mask]);
 
  const transformOpts = React.useMemo(
    () => ({
      currency,
      locale,
    }),
    [currency, locale],
  );
 
  const placeholderValue = React.useMemo(() => {
    if (withoutMask) return placeholder;
 
    if (placeholder) {
      if (focused && maskPattern) {
        if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
          try {
            const formatter = getCachedFormatter(locale, {
              currency,
              minimumFractionDigits: 2,
              maximumFractionDigits: 2,
            });
            return formatter.format(0);
          } catch {
            return `${getCachedFormatter(DEFAULT_LOCALE, {
              currency: DEFAULT_CURRENCY,
              minimumFractionDigits: 2,
              maximumFractionDigits: 2,
            }).format(0)}`;
          }
        }
        if (mask === "percentage" || maskPattern.pattern.includes("%")) {
          return ZERO_PERCENTAGE;
        }
        return maskPattern?.placeholder ?? placeholder;
      }
      return placeholder;
    }
 
    if (focused && maskPattern) {
      if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
        try {
          const formatter = getCachedFormatter(locale, {
            currency,
            minimumFractionDigits: 2,
            maximumFractionDigits: 2,
          });
          return formatter.format(0);
        } catch {
          return CURRENCY_FALLBACK;
        }
      }
      if (mask === "percentage" || maskPattern.pattern.includes("%")) {
        return ZERO_PERCENTAGE;
      }
      return maskPattern?.placeholder;
    }
 
    return undefined;
  }, [placeholder, withoutMask, maskPattern, focused, mask, currency, locale]);
 
  const displayValue = React.useMemo(() => {
    if (withoutMask || !maskPattern || !value) return value ?? "";
    const unmasked = getUnmaskedValue({
      value,
      transform: maskPattern.transform,
      ...transformOpts,
    });
    return applyMask({
      value: unmasked,
      pattern: maskPattern.pattern,
      ...transformOpts,
      mask,
    });
  }, [value, maskPattern, withoutMask, transformOpts, mask]);
 
  const tokenCount = React.useMemo(() => {
    if (!maskPattern || CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern))
      return undefined;
    return maskPattern.pattern.match(REGEX_CACHE.hashPattern)?.length ?? 0;
  }, [maskPattern]);
 
  const calculatedMaxLength = tokenCount
    ? maskPattern?.pattern.length
    : maxLength;
 
  const calculatedInputMode = React.useMemo(() => {
    if (inputMode) return inputMode;
    if (!maskPattern) return undefined;
 
    if (mask === "currency" || mask === "percentage" || mask === "ipv4") {
      return "decimal";
    }
 
    if (typeof mask === "string" && NUMERIC_MASK_PATTERNS.test(mask)) {
      return "numeric";
    }
    return undefined;
  }, [maskPattern, mask, inputMode]);
 
  const shouldValidate = React.useCallback(
    (trigger: "change" | "blur") => {
      if (!onValidate || !maskPattern?.validate) return false;
 
      switch (validationMode) {
        case "onChange":
          return trigger === "change";
        case "onBlur":
          return trigger === "blur";
        case "onSubmit":
          return false;
        case "onTouched":
          return touched ? trigger === "change" : trigger === "blur";
        case "all":
          return true;
        default:
          return trigger === "change";
      }
    },
    [onValidate, maskPattern, validationMode, touched],
  );
 
  const validationOpts = React.useMemo(
    () => ({
      min: typeof min === "string" ? parseFloat(min) : min,
      max: typeof max === "string" ? parseFloat(max) : max,
    }),
    [min, max],
  );
 
  const onInputValidate = React.useCallback(
    (unmaskedValue: string) => {
      if (onValidate && maskPattern?.validate) {
        const isValid = maskPattern.validate(unmaskedValue, validationOpts);
        onValidate(isValid, unmaskedValue);
      }
    },
    [onValidate, maskPattern?.validate, validationOpts],
  );
 
  const onValueChange = React.useCallback(
    (event: React.ChangeEvent<InputElement>) => {
      const inputValue = event.target.value;
      let newValue = inputValue;
      let unmaskedValue = inputValue;
 
      if (composing) {
        if (!isControlled) setInternalValue(inputValue);
        return;
      }
 
      if (withoutMask || !maskPattern) {
        if (!isControlled) setInternalValue(inputValue);
        if (shouldValidate("change")) onValidate?.(true, inputValue);
        onValueChangeProp?.(inputValue, inputValue);
        return;
      }
 
      if (maskPattern) {
        unmaskedValue = getUnmaskedValue({
          value: inputValue,
          transform: maskPattern.transform,
          ...transformOpts,
        });
        newValue = applyMask({
          value: unmaskedValue,
          pattern: maskPattern.pattern,
          ...transformOpts,
          mask,
        });
 
        if (inputRef.current && newValue !== inputValue) {
          const inputElement = inputRef.current;
          if (!(inputElement instanceof HTMLInputElement)) return;
          inputElement.value = newValue;
 
          const currentUnmasked = getUnmaskedValue({
            value: newValue,
            transform: maskPattern.transform,
            ...transformOpts,
          });
 
          let newCursorPosition: number;
          if (CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern)) {
            newCursorPosition = getCurrencyCaretPosition(
              newValue,
              mask,
              transformOpts,
            );
          } else {
            newCursorPosition = getPatternCaretPosition(
              newValue,
              maskPattern,
              currentUnmasked,
            );
          }
 
          if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
            if (mask === "currency") {
              const currencyAtEnd = isCurrencyAtEnd(transformOpts);
              if (!currencyAtEnd) {
                newCursorPosition = Math.max(1, newCursorPosition);
              }
            } else {
              newCursorPosition = Math.max(1, newCursorPosition);
            }
          } else if (maskPattern.pattern.includes("%")) {
            newCursorPosition = Math.min(
              newValue.length - 1,
              newCursorPosition,
            );
          }
 
          newCursorPosition = Math.min(newCursorPosition, newValue.length);
 
          inputElement.setSelectionRange(newCursorPosition, newCursorPosition);
        }
      }
 
      if (!isControlled) {
        setInternalValue(newValue);
      }
 
      if (shouldValidate("change")) {
        onInputValidate(unmaskedValue);
      }
 
      onValueChangeProp?.(newValue, unmaskedValue);
    },
    [
      maskPattern,
      isControlled,
      onValueChangeProp,
      onValidate,
      onInputValidate,
      composing,
      shouldValidate,
      withoutMask,
      transformOpts,
      mask,
    ],
  );
 
  const onFocus = React.useCallback(
    (event: React.FocusEvent<InputElement>) => {
      onFocusProp?.(event);
      if (event.defaultPrevented) return;
 
      setFocused(true);
    },
    [onFocusProp],
  );
 
  const onBlur = React.useCallback(
    (event: React.FocusEvent<InputElement>) => {
      onBlurProp?.(event);
      if (event.defaultPrevented) return;
 
      setFocused(false);
 
      if (!touched) {
        setTouched(true);
      }
 
      if (shouldValidate("blur")) {
        const currentValue = event.target.value;
        const unmaskedValue = maskPattern
          ? getUnmaskedValue({
              value: currentValue,
              transform: maskPattern.transform,
              ...transformOpts,
            })
          : currentValue;
        onInputValidate(unmaskedValue);
      }
    },
    [
      onBlurProp,
      touched,
      shouldValidate,
      onInputValidate,
      maskPattern,
      transformOpts,
    ],
  );
 
  const onCompositionStart = React.useCallback(
    (event: React.CompositionEvent<InputElement>) => {
      onCompositionStartProp?.(event);
      if (event.defaultPrevented) return;
 
      setComposing(true);
    },
    [onCompositionStartProp],
  );
 
  const onCompositionEnd = React.useCallback(
    (e: React.CompositionEvent<InputElement>) => {
      onCompositionEndProp?.(e);
      if (e.defaultPrevented) return;
 
      setComposing(false);
 
      const inputElement = inputRef.current;
      if (!inputElement) return;
      if (!(inputElement instanceof HTMLInputElement)) return;
      const inputValue = inputElement.value;
 
      if (!maskPattern || withoutMask) {
        if (!isControlled) setInternalValue(inputValue);
        if (shouldValidate("change")) onValidate?.(true, inputValue);
        onValueChangeProp?.(inputValue, inputValue);
        return;
      }
 
      const unmasked = getUnmaskedValue({
        value: inputValue,
        transform: maskPattern.transform,
        ...transformOpts,
      });
      const masked = applyMask({
        value: unmasked,
        pattern: maskPattern.pattern,
        ...transformOpts,
        mask,
      });
 
      if (!isControlled) setInternalValue(masked);
      if (shouldValidate("change")) onInputValidate(unmasked);
      onValueChangeProp?.(masked, unmasked);
    },
    [
      onCompositionEndProp,
      maskPattern,
      withoutMask,
      isControlled,
      shouldValidate,
      onValidate,
      onValueChangeProp,
      transformOpts,
      mask,
      onInputValidate,
    ],
  );
 
  const onPaste = React.useCallback(
    (event: React.ClipboardEvent<InputElement>) => {
      onPasteProp?.(event);
      if (event.defaultPrevented) return;
 
      if (withoutMask || !maskPattern) return;
 
      if (mask === "ipv4") return;
 
      const target = event.target as InputElement;
      if (!(target instanceof HTMLInputElement)) return;
 
      const pastedData = event.clipboardData.getData("text");
      if (!pastedData) return;
 
      event.preventDefault();
 
      const currentValue = target.value;
      const selectionStart = target.selectionStart ?? 0;
      const selectionEnd = target.selectionEnd ?? 0;
 
      const beforeSelection = currentValue.slice(0, selectionStart);
      const afterSelection = currentValue.slice(selectionEnd);
      const newInputValue = beforeSelection + pastedData + afterSelection;
 
      const unmasked = getUnmaskedValue({
        value: newInputValue,
        transform: maskPattern.transform,
        ...transformOpts,
      });
      const newMaskedValue = applyMask({
        value: unmasked,
        pattern: maskPattern.pattern,
        ...transformOpts,
        mask,
      });
 
      target.value = newMaskedValue;
 
      if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
        const currencyAtEnd = isCurrencyAtEnd(transformOpts);
        const caret = currencyAtEnd
          ? newMaskedValue.search(/\s*[^\d\s]+$/)
          : newMaskedValue.length;
        target.setSelectionRange(caret, caret);
        return;
      }
 
      if (maskPattern.pattern.includes("%")) {
        target.setSelectionRange(
          newMaskedValue.length - 1,
          newMaskedValue.length - 1,
        );
        return;
      }
 
      let newCursorPosition = newMaskedValue.length;
      try {
        const unmaskedCount = unmasked.length;
        let position = 0;
        let count = 0;
 
        for (
          let i = 0;
          i < maskPattern.pattern.length && i < newMaskedValue.length;
          i++
        ) {
          if (maskPattern.pattern[i] === "#") {
            count++;
            if (count <= unmaskedCount) {
              position = i + 1;
            }
          }
        }
        newCursorPosition = position;
      } catch {
        // fallback to end
      }
 
      target.setSelectionRange(newCursorPosition, newCursorPosition);
 
      if (!isControlled) setInternalValue(newMaskedValue);
      if (shouldValidate("change")) onInputValidate(unmasked);
      onValueChangeProp?.(newMaskedValue, unmasked);
    },
    [
      onPasteProp,
      withoutMask,
      maskPattern,
      mask,
      transformOpts,
      isControlled,
      shouldValidate,
      onInputValidate,
      onValueChangeProp,
    ],
  );
 
  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<InputElement>) => {
      onKeyDownProp?.(event);
      if (event.defaultPrevented) return;
 
      if (withoutMask || !maskPattern) return;
 
      if (mask === "ipv4") return;
 
      if (event.key === "Backspace") {
        const target = event.target as InputElement;
        if (!(target instanceof HTMLInputElement)) return;
        const cursorPosition = target.selectionStart ?? 0;
        const selectionEnd = target.selectionEnd ?? 0;
        const currentValue = target.value;
 
        if (
          mask === "currency" ||
          mask === "percentage" ||
          maskPattern.pattern.includes("$") ||
          maskPattern.pattern.includes("€") ||
          maskPattern.pattern.includes("%")
        ) {
          return;
        }
 
        if (cursorPosition !== selectionEnd) {
          return;
        }
 
        if (cursorPosition > 0) {
          const charBeforeCursor = currentValue[cursorPosition - 1];
 
          const isLiteral = maskPattern.pattern[cursorPosition - 1] !== "#";
 
          if (charBeforeCursor && isLiteral) {
            event.preventDefault();
 
            const unmaskedIndex = toUnmaskedIndex({
              masked: currentValue,
              pattern: maskPattern.pattern,
              caret: cursorPosition,
            });
            if (unmaskedIndex > 0) {
              const currentUnmasked = getUnmaskedValue({
                value: currentValue,
                transform: maskPattern.transform,
                ...transformOpts,
              });
              const nextUnmasked =
                currentUnmasked.slice(0, unmaskedIndex - 1) +
                currentUnmasked.slice(unmaskedIndex);
              const nextMasked = applyMask({
                value: nextUnmasked,
                pattern: maskPattern.pattern,
                ...transformOpts,
                mask,
              });
 
              target.value = nextMasked;
              const nextCaret = fromUnmaskedIndex({
                masked: nextMasked,
                pattern: maskPattern.pattern,
                unmaskedIndex: unmaskedIndex - 1,
              });
              target.setSelectionRange(nextCaret, nextCaret);
 
              onValueChangeProp?.(nextMasked, nextUnmasked);
            }
            return;
          }
        }
      }
 
      if (event.key === "Delete") {
        const target = event.target as InputElement;
        if (!(target instanceof HTMLInputElement)) return;
        const cursorPosition = target.selectionStart ?? 0;
        const selectionEnd = target.selectionEnd ?? 0;
        const currentValue = target.value;
 
        if (
          mask === "currency" ||
          mask === "percentage" ||
          maskPattern.pattern.includes("$") ||
          maskPattern.pattern.includes("€") ||
          maskPattern.pattern.includes("%")
        ) {
          return;
        }
 
        if (cursorPosition !== selectionEnd) {
          return;
        }
 
        if (cursorPosition < currentValue.length) {
          const charAtCursor = currentValue[cursorPosition];
 
          const isLiteral = maskPattern.pattern[cursorPosition] !== "#";
 
          if (charAtCursor && isLiteral) {
            event.preventDefault();
 
            const unmaskedIndex = toUnmaskedIndex({
              masked: currentValue,
              pattern: maskPattern.pattern,
              caret: cursorPosition,
            });
            const currentUnmasked = getUnmaskedValue({
              value: currentValue,
              transform: maskPattern.transform,
              ...transformOpts,
            });
 
            if (unmaskedIndex < currentUnmasked.length) {
              const nextUnmasked =
                currentUnmasked.slice(0, unmaskedIndex) +
                currentUnmasked.slice(unmaskedIndex + 1);
              const nextMasked = applyMask({
                value: nextUnmasked,
                pattern: maskPattern.pattern,
                ...transformOpts,
                mask,
              });
 
              target.value = nextMasked;
              const nextCaret = fromUnmaskedIndex({
                masked: nextMasked,
                pattern: maskPattern.pattern,
                unmaskedIndex: unmaskedIndex,
              });
              target.setSelectionRange(nextCaret, nextCaret);
 
              onValueChangeProp?.(nextMasked, nextUnmasked);
            }
            return;
          }
        }
      }
    },
    [
      maskPattern,
      onKeyDownProp,
      onValueChangeProp,
      transformOpts,
      mask,
      withoutMask,
    ],
  );
 
  const InputPrimitive = asChild ? Slot : "input";
 
  return (
    <InputPrimitive
      aria-invalid={invalid}
      data-disabled={disabled ? "" : undefined}
      data-invalid={invalid ? "" : undefined}
      data-readonly={readOnly ? "" : undefined}
      data-required={required ? "" : undefined}
      data-slot="mask-input"
      {...inputProps}
      className={cn(
        "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm 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,
      )}
      placeholder={placeholderValue}
      ref={composedRef}
      value={displayValue}
      disabled={disabled}
      maxLength={calculatedMaxLength}
      readOnly={readOnly}
      required={required}
      inputMode={calculatedInputMode}
      min={min}
      max={max}
      onFocus={onFocus}
      onBlur={onBlur}
      onKeyDown={onKeyDown}
      onPaste={onPaste}
      onChange={onValueChange}
      onCompositionStart={onCompositionStart}
      onCompositionEnd={onCompositionEnd}
    />
  );
}
 
export {
  MaskInput,
  //
  MASK_PATTERNS,
  //
  applyMask,
  applyCurrencyMask,
  applyPercentageMask,
  getUnmaskedValue,
  toUnmaskedIndex,
  fromUnmaskedIndex,
  //
  type MaskPattern,
  type MaskInputProps,
};

Layout

Import and use the component directly.

import { MaskInput } from "@/components/ui/mask-input";

<MaskInput 
  mask="phone" 
  placeholder="Enter phone number"
  onValueChange={(masked, unmasked) => {
    console.log('Masked:', masked);     // "(555) 123-4567"
    console.log('Unmasked:', unmasked); // "5551234567"
  }}
/>

Features

  • Smart cursor positioning - Cursor stays in the correct position during typing and pasting
  • Paste support - Intelligently handles pasted content with proper formatting
  • Built-in patterns - Common formats like phone, SSN, date, credit card, etc.
  • Custom patterns - Create your own mask patterns with validation
  • TypeScript support - Full type safety with IntelliSense
  • Accessibility - ARIA attributes and keyboard navigation
  • Form integration - Works seamlessly with form libraries
  • Composition support - Use asChild prop to render as a different component using Radix Slot

Examples

Built-in Patterns

Use predefined mask patterns for common input formats.

"use client";
 
import * as React from "react";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
 
export function MaskInputPatternsDemo() {
  const [values, setValues] = React.useState({
    phone: "",
    ssn: "",
    date: "",
    time: "",
    creditCard: "",
    zipCode: "",
  });
 
  const handleValueChange =
    (field: keyof typeof values) => (maskedValue: string) => {
      setValues((prev) => ({
        ...prev,
        [field]: maskedValue,
      }));
    };
 
  return (
    <div className="grid w-full max-w-2xl grid-cols-1 gap-6 md:grid-cols-2">
      <div className="space-y-2">
        <Label htmlFor="phone">Phone Number</Label>
        <MaskInput
          id="phone"
          mask="phone"
          value={values.phone}
          onValueChange={handleValueChange("phone")}
          placeholder="(555) 123-4567"
        />
      </div>
 
      <div className="space-y-2">
        <Label htmlFor="ssn">Social Security Number</Label>
        <MaskInput
          id="ssn"
          mask="ssn"
          value={values.ssn}
          onValueChange={handleValueChange("ssn")}
          placeholder="123-45-6789"
        />
      </div>
 
      <div className="space-y-2">
        <Label htmlFor="date">Date</Label>
        <MaskInput
          id="date"
          mask="date"
          value={values.date}
          onValueChange={handleValueChange("date")}
          placeholder="mm/dd/yyyy"
        />
      </div>
 
      <div className="space-y-2">
        <Label htmlFor="time">Time</Label>
        <MaskInput
          id="time"
          mask="time"
          value={values.time}
          onValueChange={handleValueChange("time")}
          placeholder="hh:mm"
        />
      </div>
 
      <div className="space-y-2">
        <Label htmlFor="creditCard">Credit Card</Label>
        <MaskInput
          id="creditCard"
          mask="creditCard"
          value={values.creditCard}
          onValueChange={handleValueChange("creditCard")}
          placeholder="1234 5678 9012 3456"
        />
      </div>
 
      <div className="space-y-2">
        <Label htmlFor="zipCode">ZIP Code</Label>
        <MaskInput
          id="zipCode"
          mask="zipCode"
          value={values.zipCode}
          onValueChange={handleValueChange("zipCode")}
          placeholder="12345"
        />
      </div>
    </div>
  );
}

Currency Formatting

The currency mask uses the Intl.NumberFormat API for localization and currency formatting.

// Default USD formatting
<MaskInput mask="currency" />

// Euro formatting with German locale
<MaskInput 
  mask="currency" 
  currency="EUR" 
  locale="de-DE" 
/>

// Japanese Yen formatting
<MaskInput 
  mask="currency" 
  currency="JPY" 
  locale="ja-JP" 
/>

// British Pound formatting
<MaskInput 
  mask="currency" 
  currency="GBP" 
  locale="en-GB" 
/>

With custom patterns

Create custom mask patterns for specific formatting needs.

"use client";
 
import * as React from "react";
import { z } from "zod";
import { Label } from "@/components/ui/label";
import { MaskInput, type MaskPattern } from "@/components/ui/mask-input";
 
// Custom license plate pattern (e.g., ABC-1234)
const licensePattern: MaskPattern = {
  pattern: "###-####",
  placeholder: "ABC-1234",
  transform: (value) => value.replace(/[^A-Z0-9]/gi, "").toUpperCase(),
  validate: (value) => {
    const cleaned = value.replace(/[^A-Z0-9]/gi, "").toUpperCase();
    return cleaned.length === 7 && /^[A-Z]{3}[0-9]{4}$/.test(cleaned);
  },
};
 
// Custom product code pattern (e.g., PRD-ABC-123)
const productCodePattern: MaskPattern = {
  pattern: "###-###-###",
  placeholder: "PRD-ABC-123",
  transform: (value) => {
    const cleaned = value.replace(/[^A-Z0-9]/gi, "").toUpperCase();
 
    // If empty or just partial PRD, allow it to be empty
    if (cleaned.length === 0) {
      return "";
    }
 
    // If user is typing and it doesn't start with PRD, prepend it
    // But only if they have more than just partial PRD characters
    if (!cleaned.startsWith("PRD")) {
      // If user typed partial PRD (like "P" or "PR"), don't auto-complete
      if (cleaned.length <= 2 && "PRD".startsWith(cleaned)) {
        return cleaned;
      }
      // Otherwise, prepend PRD to their input
      return `PRD${cleaned}`;
    }
 
    // If it already starts with PRD, keep as is
    return cleaned;
  },
  validate: (value) => {
    const cleaned = value.replace(/[^A-Z0-9]/gi, "").toUpperCase();
    return cleaned.length === 9 && cleaned.startsWith("PRD");
  },
};
 
export function MaskInputCustomDemo() {
  const [licenseValue, setLicenseValue] = React.useState("");
  const [productCodeValue, setProductCodeValue] = React.useState("");
  const [isLicenseValid, setIsLicenseValid] = React.useState(true);
  const [isProductCodeValid, setIsProductCodeValid] = React.useState(true);
 
  return (
    <div className="flex w-full max-w-sm flex-col gap-6">
      <div className="flex flex-col gap-2">
        <Label htmlFor="license">License plate</Label>
        <MaskInput
          id="license"
          mask={licensePattern}
          value={licenseValue}
          onValueChange={setLicenseValue}
          placeholder="ABC-1234"
          invalid={!isLicenseValid}
          onValidate={setIsLicenseValid}
        />
        <p className="text-muted-foreground text-sm">
          Enter license plate (3 letters, 4 numbers)
        </p>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="product">Product code</Label>
        <MaskInput
          id="product"
          mask={productCodePattern}
          value={productCodeValue}
          onValueChange={setProductCodeValue}
          placeholder="PRD-ABC-123"
          invalid={!isProductCodeValid}
          onValidate={setIsProductCodeValid}
        />
        <p className="text-muted-foreground text-sm">
          Enter product code (PRD-XXX-XXX format)
        </p>
      </div>
    </div>
  );
}

With validation modes

Control when validation occurs with different validation modes, similar to react-hook-form.

"use client";
 
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
 
const modes = [
  {
    label: "onChange",
    description: "Validates on every keystroke",
    value: "onChange" as const,
  },
  {
    label: "onBlur",
    description: "Validates when field loses focus",
    value: "onBlur" as const,
  },
  {
    label: "onTouched",
    description: "Validates after first blur, then on change",
    value: "onTouched" as const,
  },
  {
    label: "onSubmit",
    description: "Validates only on form submission",
    value: "onSubmit" as const,
  },
];
 
export function MaskInputValidationModesDemo() {
  const [validationStates, setValidationStates] = React.useState({
    onChange: { isValid: true, message: "" },
    onBlur: { isValid: true, message: "" },
    onTouched: { isValid: true, message: "" },
    onSubmit: { isValid: true, message: "" },
  });
 
  const [values, setValues] = React.useState({
    onChange: "",
    onBlur: "",
    onTouched: "",
    onSubmit: "",
  });
 
  const [submitAttempted, setSubmitAttempted] = React.useState(false);
 
  const onValidate = React.useCallback(
    (mode: keyof typeof validationStates) =>
      (isValid: boolean, unmaskedValue: string) => {
        const message = isValid
          ? `✓ Valid (${unmaskedValue.length}/10)`
          : `✗ Invalid (${unmaskedValue.length}/10)`;
 
        setValidationStates((prev) => ({
          ...prev,
          [mode]: { isValid, message },
        }));
      },
    [],
  );
 
  const onValueChange = React.useCallback(
    (mode: keyof typeof values) =>
      (_maskedValue: string, unmaskedValue: string) => {
        setValues((prev) => ({
          ...prev,
          [mode]: unmaskedValue,
        }));
      },
    [],
  );
 
  const onSubmit = React.useCallback(
    (event: React.FormEvent) => {
      event.preventDefault();
      setSubmitAttempted(true);
 
      const unmaskedValue = values.onSubmit;
      const isValid = unmaskedValue.length === 10;
      const message = isValid
        ? `✓ Valid (${unmaskedValue.length}/10)`
        : `✗ Invalid (${unmaskedValue.length}/10)`;
 
      setValidationStates((prev) => ({
        ...prev,
        onSubmit: { isValid, message },
      }));
    },
    [values.onSubmit],
  );
 
  return (
    <div className="grid w-full gap-4 sm:grid-cols-2">
      {modes.map((mode) => (
        <ValidationModeCard
          key={mode.value}
          mode={mode}
          value={values[mode.value]}
          validationState={validationStates[mode.value]}
          onValueChange={onValueChange(mode.value)}
          onValidate={onValidate(mode.value)}
          onSubmit={mode.value === "onSubmit" ? onSubmit : undefined}
          submitAttempted={submitAttempted}
        />
      ))}
    </div>
  );
}
 
interface ValidationModeCardProps {
  mode: (typeof modes)[number];
  value: string;
  validationState: { isValid: boolean; message: string };
  onValueChange: (maskedValue: string, unmaskedValue: string) => void;
  onValidate: (isValid: boolean, unmaskedValue: string) => void;
  onSubmit?: (event: React.FormEvent) => void;
  submitAttempted: boolean;
}
 
function ValidationModeCard({
  mode,
  value,
  validationState,
  onValueChange,
  onValidate,
  onSubmit,
  submitAttempted,
}: ValidationModeCardProps) {
  const inputContent = (
    <div className="flex flex-col gap-1">
      <Label htmlFor={`phone-${mode.value}`} className="sr-only">
        Phone Number
      </Label>
      <MaskInput
        id={`phone-${mode.value}`}
        mask="phone"
        validationMode={mode.value}
        value={value}
        onValueChange={onValueChange}
        onValidate={onValidate}
        placeholder="(555) 123-4567"
        invalid={!validationState.isValid}
        className="h-8 text-sm"
      />
    </div>
  );
 
  return (
    <div className="flex flex-col gap-3 rounded-md bg-card p-4">
      <div className="flex flex-col gap-1">
        <h4 className="font-medium text-xs">{mode.label}</h4>
        <p className="text-muted-foreground text-xs leading-tight">
          {mode.description}
        </p>
      </div>
      {onSubmit ? (
        <form onSubmit={onSubmit} className="flex flex-col gap-2">
          {inputContent}
          <Button type="submit" size="sm" className="h-7 text-xs">
            Submit
          </Button>
        </form>
      ) : (
        inputContent
      )}
      <div className="flex items-center gap-1">
        <Badge
          variant={validationState.isValid ? "default" : "destructive"}
          className="h-5 px-1.5 text-xs"
        >
          {validationState.isValid ? "Valid" : "Invalid"}
        </Badge>
        <span className="text-muted-foreground text-xs">
          {validationState.message ||
            (mode.value === "onSubmit" && !submitAttempted
              ? "Click 'Submit' to check..."
              : "Start typing to see validation...")}
        </span>
      </div>
    </div>
  );
}

With form

Integrate masked inputs with form validation using react-hook-form.

"use client";
 
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { MaskInput } from "@/components/ui/mask-input";
 
const formSchema = z.object({
  phone: z.string().min(10, "Phone number must be at least 10 digits"),
  ssn: z.string().min(9, "SSN must be 9 digits"),
  birthDate: z.string().min(8, "Birth date is required"),
  emergencyContact: z.string().min(10, "Emergency contact is required"),
});
 
type FormSchema = z.infer<typeof formSchema>;
 
export function MaskInputFormDemo() {
  const form = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      phone: "",
      ssn: "",
      birthDate: "",
      emergencyContact: "",
    },
  });
 
  function onSubmit(values: FormSchema) {
    toast.success("Form submitted successfully!");
    console.log(values);
  }
 
  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="grid gap-6 md:grid-cols-2"
      >
        <FormField
          control={form.control}
          name="phone"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Phone Number</FormLabel>
              <FormControl>
                <MaskInput
                  mask="phone"
                  value={field.value}
                  onValueChange={(_maskedValue, unmaskedValue) => {
                    field.onChange(unmaskedValue);
                  }}
                  placeholder="(555) 123-4567"
                  invalid={!!form.formState.errors.phone}
                />
              </FormControl>
              <FormDescription>Enter your primary phone number</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="ssn"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Social Security Number</FormLabel>
              <FormControl>
                <MaskInput
                  mask="ssn"
                  value={field.value}
                  onValueChange={(_maskedValue, unmaskedValue) => {
                    field.onChange(unmaskedValue);
                  }}
                  placeholder="123-45-6789"
                  invalid={!!form.formState.errors.ssn}
                />
              </FormControl>
              <FormDescription>
                Enter your social security number
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="birthDate"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Birth Date</FormLabel>
              <FormControl>
                <MaskInput
                  mask="date"
                  value={field.value}
                  onValueChange={(_maskedValue, unmaskedValue) => {
                    field.onChange(unmaskedValue);
                  }}
                  placeholder="mm/dd/yyyy"
                  invalid={!!form.formState.errors.birthDate}
                />
              </FormControl>
              <FormDescription>Enter your date of birth</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="emergencyContact"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Emergency Contact</FormLabel>
              <FormControl>
                <MaskInput
                  mask="phone"
                  value={field.value}
                  onValueChange={(_maskedValue, unmaskedValue) => {
                    field.onChange(unmaskedValue);
                  }}
                  placeholder="(555) 987-6543"
                  invalid={!!form.formState.errors.emergencyContact}
                />
              </FormControl>
              <FormDescription>
                Enter emergency contact phone number
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <div className="flex w-full justify-end gap-2 md:col-span-2">
          <Button type="button" variant="outline" onClick={() => form.reset()}>
            Reset
          </Button>
          <Button type="submit">Submit</Button>
        </div>
      </form>
    </Form>
  );
}

Built-in Mask Patterns

The component includes several predefined mask patterns:

PatternFormatExampleDescription
phone(###) ###-####(555) 123-4567US phone number
ssn###-##-####123-45-6789Social Security Number
date##/##/####12/25/2023Date (MM/DD/YYYY)
time##:##14:30Time (HH:MM)
creditCard#### #### #### ####1234 5678 9012 3456Credit card number
zipCode#####12345US ZIP code
zipCodeExtended#####-####12345-6789US ZIP+4 code
currencyDynamic$1,234.56Currency formatting using Intl.NumberFormat
percentage##.##%12.34%Percentage with decimals
licensePlate###-###ABC-123License plate format
ipv4###.###.###.###192.168.1.1IPv4 address
macAddress##:##:##:##:##:##00:1B:44:11:3A:B7MAC address
isbn###-#-###-#####-#978-0-123-45678-9ISBN-13 book identifier
ein##-#######12-3456789Employer Identification Number

Custom Mask Patterns

Create custom patterns using the MaskPattern interface:

const customPattern: MaskPattern = {
  pattern: "###-###-####",
  placeholder: "ABC-123-4567",
  transform: (value, opts) => value.replace(/[^A-Z0-9]/gi, "").toUpperCase(),
  validate: (value, opts) => value.length === 10,
};

<MaskInput mask={customPattern} />

API Reference

MaskInput

The main masked input component that handles formatting and user input.

PropTypeDefault
value?
string
-
defaultValue?
string
-
onValueChange?
((maskedValue: string, unmaskedValue: string) => void)
-
onValidate?
((isValid: boolean, unmaskedValue: string) => void)
-
validationMode?
ValidationMode
-
mask?
MaskPatternKey | MaskPattern
-
currency?
string
-
locale?
string
-
asChild?
boolean
false
invalid?
boolean
-
withoutMask?
boolean
-

MaskPattern

Interface for creating custom mask patterns.

PropTypeDefault
pattern
string
-
placeholder?
string
-
transform?
((value: string, opts?: TransformOptions | undefined) => string)
-
validate?
((value: string, opts?: ValidateOptions | undefined) => boolean)
-

MaskPatternKey

Predefined mask pattern keys for common input formats.

PatternDescription
phoneUS phone number
ssnSocial Security Number
dateDate (MM/DD/YYYY)
timeTime (HH:MM)
creditCardCredit card number
zipCodeUS ZIP code
zipCodeExtendedUS ZIP+4 code
currencyCurrency formatting using Intl.NumberFormat
percentagePercentage with decimals
licensePlateLicense plate format
ipv4IPv4 address
macAddressMAC address
isbnISBN-13 book identifier
einEmployer Identification Number

TransformOptions

Options passed to the transform function for advanced formatting.

PropTypeDefault
currency?
string
-
locale?
string
-

ValidateOptions

Options passed to the validate function for enhanced validation.

PropTypeDefault
min?
number
-
max?
number
-

Data Attributes

Data AttributeValue
[data-disabled]Present when the input is disabled.
[data-invalid]Present when the input has validation errors.
[data-readonly]Present when the input is read-only.
[data-required]Present when the input is required.

Accessibility

Keyboard Interactions

KeyDescription
TabMoves focus to or away from the input.
Shift + TabMoves focus to the previous focusable element.
BackspaceRemoves the previous character, intelligently handling mask characters.
DeleteRemoves the next character.
Ctrl + VCmd + VPastes content with intelligent mask formatting.
Ctrl + ACmd + ASelects all input content.