Dice UI
Components

Mask Input

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

API
"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;
}
 
export function MaskInputDemo() {
  const id = React.useId();
  const [input, setInput] = React.useState<Input>({
    phone: "",
    date: "",
    dollar: "",
    euro: "",
    creditCard: "",
    percentage: "",
  });
 
  const onValueChange = React.useCallback(
    (field: keyof Input) => (maskedValue: string) => {
      setInput((prev) => ({
        ...prev,
        [field]: maskedValue,
      }));
    },
    [],
  );
 
  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")}
        />
        <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="Enter your birth date"
          value={input.date}
          onValueChange={onValueChange("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`}>Currency</Label>
        <MaskInput
          id={`${id}-dollar`}
          mask="currency"
          placeholder="$0.00"
          value={input.dollar}
          onValueChange={onValueChange("dollar")}
        />
        <p className="text-muted-foreground text-sm">Enter your currency</p>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor={`${id}-euro`}>Currency (German)</Label>
        <MaskInput
          id={`${id}-euro`}
          mask="currency"
          currency="EUR"
          locale="de-DE"
          placeholder="0,00 €"
          value={input.euro}
          onValueChange={onValueChange("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="Enter your credit card number"
          value={input.creditCard}
          onValueChange={onValueChange("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</Label>
        <MaskInput
          id={`${id}-percentage`}
          mask="percentage"
          placeholder="0.00%"
          min={0}
          max={100}
          value={input.percentage}
          onValueChange={onValueChange("percentage")}
        />
        <p className="text-muted-foreground text-sm">Enter a percentage</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|creditCardExpiry)$/;
const CURRENCY_PERCENTAGE_SYMBOLS = /[€$%]/;
 
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{13,19}$/,
  creditCardExpiry: /^\d{4}$/,
  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 | undefined,
  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: TransformOptions): 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: TransformOptions): 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;
  transform?: (value: string, opts?: TransformOptions) => string;
  validate?: (value: string, opts?: ValidateOptions) => boolean;
}
 
type MaskPatternKey =
  | "phone"
  | "ssn"
  | "date"
  | "time"
  | "creditCard"
  | "creditCardExpiry"
  | "zipCode"
  | "zipCodeExtended"
  | "currency"
  | "percentage"
  | "licensePlate"
  | "ipv4"
  | "macAddress"
  | "isbn"
  | "ein";
 
const MASK_PATTERNS: Record<MaskPatternKey, MaskPattern> = {
  phone: {
    pattern: "(###) ###-####",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.phone.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  ssn: {
    pattern: "###-##-####",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.ssn.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  date: {
    pattern: "##/##/####",
    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: "##:##",
    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: "#### #### #### ####",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) => {
      const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
      if (!REGEX_CACHE.creditCard.test(cleaned)) return false;
 
      let sum = 0;
      let isEven = false;
      for (let i = cleaned.length - 1; i >= 0; i--) {
        const digitChar = cleaned[i];
        if (!digitChar) continue;
        let digit = parseInt(digitChar, 10);
        if (isEven) {
          digit *= 2;
          if (digit > 9) {
            digit -= 9;
          }
        }
        sum += digit;
        isEven = !isEven;
      }
      return sum % 10 === 0;
    },
  },
  creditCardExpiry: {
    pattern: "##/##",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) => {
      const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
      if (!REGEX_CACHE.creditCardExpiry.test(cleaned)) return false;
 
      const month = parseInt(cleaned.substring(0, 2), 10);
      const year = parseInt(cleaned.substring(2, 4), 10);
 
      if (month < 1 || month > 12) return false;
 
      const now = new Date();
      const currentYear = now.getFullYear();
      const currentMonth = now.getMonth() + 1;
 
      const fullYear = year <= 75 ? 2000 + year : 1900 + year;
 
      if (
        fullYear < currentYear ||
        (fullYear === currentYear && month < currentMonth)
      ) {
        return false;
      }
 
      const maxYear = currentYear + 50;
      if (fullYear > maxYear) {
        return false;
      }
 
      return true;
    },
  },
  zipCode: {
    pattern: "#####",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.zipCode.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  zipCodeExtended: {
    pattern: "#####-####",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.zipCodeExtended.test(
        value.replace(REGEX_CACHE.nonDigits, ""),
      ),
  },
  currency: {
    pattern: "$###,###.##",
    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: "##.##%",
    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: "###-###",
    transform: (value) =>
      value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(),
    validate: (value) => REGEX_CACHE.licensePlate.test(value),
  },
  ipv4: {
    pattern: "###.###.###.###",
    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: "##:##:##:##:##:##",
    transform: (value) =>
      value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(),
    validate: (value) => REGEX_CACHE.macAddress.test(value),
  },
  isbn: {
    pattern: "###-#-###-#####-#",
    transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
    validate: (value) =>
      REGEX_CACHE.isbn.test(value.replace(REGEX_CACHE.nonDigits, "")),
  },
  ein: {
    pattern: "##-#######",
    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;
}
 
function getCurrencyCaretPosition(opts: {
  newValue: string;
  mask: MaskPatternKey | MaskPattern | undefined;
  transformOpts: TransformOptions;
  oldCursorPosition?: number;
  oldValue?: string;
  previousUnmasked?: string;
}): number {
  const {
    newValue,
    mask,
    transformOpts,
    oldCursorPosition,
    oldValue,
    previousUnmasked,
  } = opts;
 
  if (
    oldCursorPosition !== undefined &&
    oldValue &&
    previousUnmasked !== undefined
  ) {
    if (oldCursorPosition < oldValue.length) {
      const digitsBeforeCursor = oldValue
        .substring(0, oldCursorPosition)
        .replace(/\D/g, "").length;
 
      let digitCount = 0;
      for (let i = 0; i < newValue.length; i++) {
        if (/\d/.test(newValue[i] ?? "")) {
          digitCount++;
          if (digitCount === digitsBeforeCursor) {
            return i + 1;
          }
        }
      }
    }
  }
 
  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(opts: {
  newValue: string;
  maskPattern: MaskPattern;
  currentUnmasked: string;
  oldCursorPosition?: number;
  oldValue?: string;
  previousUnmasked?: string;
}): number {
  const {
    newValue,
    maskPattern,
    currentUnmasked,
    oldCursorPosition,
    oldValue,
    previousUnmasked,
  } = opts;
  let position = 0;
  let unmaskedCount = 0;
 
  if (
    oldCursorPosition !== undefined &&
    oldValue &&
    previousUnmasked !== undefined
  ) {
    const oldUnmaskedIndex = toUnmaskedIndex({
      masked: oldValue,
      pattern: maskPattern.pattern,
      caret: oldCursorPosition,
    });
 
    if (oldCursorPosition < oldValue.length) {
      const targetUnmaskedIndex = Math.min(
        oldUnmaskedIndex,
        currentUnmasked.length,
      );
 
      for (
        let i = 0;
        i < maskPattern.pattern.length && i < newValue.length;
        i++
      ) {
        if (maskPattern.pattern[i] === "#") {
          unmaskedCount++;
          if (unmaskedCount <= targetUnmaskedIndex) {
            position = i + 1;
          }
        }
      }
 
      return position;
    }
  }
 
  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;
}
 
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;
  maskPlaceholder?: string;
  currency?: string;
  locale?: string;
  asChild?: boolean;
  invalid?: boolean;
  withoutMask?: boolean;
}
 
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,
    maskPlaceholder,
    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 && maskPlaceholder) {
      return focused ? maskPlaceholder : placeholder;
    }
 
    if (maskPlaceholder) {
      return focused ? maskPlaceholder : undefined;
    }
 
    return placeholder;
  }, [placeholder, maskPlaceholder, focused, withoutMask]);
 
  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;
 
          const oldCursorPosition = inputElement.selectionStart ?? 0;
 
          inputElement.value = newValue;
 
          const currentUnmasked = getUnmaskedValue({
            value: newValue,
            transform: maskPattern.transform,
            ...transformOpts,
          });
 
          let newCursorPosition: number;
 
          const previousUnmasked = getUnmaskedValue({
            value,
            transform: maskPattern.transform,
            ...transformOpts,
          });
 
          if (CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern)) {
            newCursorPosition = getCurrencyCaretPosition({
              newValue,
              mask,
              transformOpts,
              oldCursorPosition,
              oldValue: inputValue,
              previousUnmasked,
            });
          } else {
            newCursorPosition = getPatternCaretPosition({
              newValue,
              maskPattern,
              currentUnmasked,
              oldCursorPosition,
              oldValue: inputValue,
              previousUnmasked,
            });
          }
 
          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,
      value,
    ],
  );
 
  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(
    (event: React.CompositionEvent<InputElement>) => {
      onCompositionEndProp?.(event);
      if (event.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];
 
          if (charBeforeCursor) {
            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];
 
          if (charAtCursor) {
            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"
  maskPlaceholder="(___) ___-____"
  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
  • Optional mask placeholders - Control when mask format hints are shown with maskPlaceholder
  • 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

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: "###-####",
  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: "###-###-###",
  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 MaskInputCustomPatternDemo() {
  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="Enter license plate"
          maskPlaceholder="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="Enter product code"
          maskPlaceholder="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}
        placeholder="Enter phone number"
        value={value}
        onValueChange={onValueChange}
        onValidate={onValidate}
        invalid={!validationState.isValid}
        className="h-8 text-sm"
      />
    </div>
  );
 
  return (
    <div className="flex flex-col gap-3 rounded-md border bg-card p-4 text-card-foreground shadow-sm">
      <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>
  );
}

Card information

Card information with credit card number, expiry date, and CVC fields.

"use client";
 
import * as React from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
 
export function MaskInputCardInformationDemo() {
  const id = React.useId();
  const [cardNumber, setCardNumber] = React.useState("");
  const [expiryDate, setExpiryDate] = React.useState("");
  const [cvc, setCvc] = React.useState("");
  const [cardNumberValid, setCardNumberValid] = React.useState(true);
  const [expiryValid, setExpiryValid] = React.useState(true);
  const [cvcValid, setCvcValid] = React.useState(true);
 
  const isFormValid = React.useMemo(() => {
    return (
      cardNumberValid &&
      expiryValid &&
      cvcValid &&
      cardNumber.trim() !== "" &&
      expiryDate.trim() !== "" &&
      cvc.trim() !== ""
    );
  }, [cardNumberValid, expiryValid, cvcValid, cardNumber, expiryDate, cvc]);
 
  const onSubmit = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      event.preventDefault();
 
      if (!isFormValid) {
        toast.error("Please fix validation errors before submitting");
        return;
      }
 
      toast.success(
        <pre className="w-full">
          {JSON.stringify({ cardNumber, expiryDate, cvc }, null, 2)}
        </pre>,
      );
    },
    [cardNumber, expiryDate, cvc, isFormValid],
  );
 
  return (
    <Card>
      <CardHeader>
        <CardTitle>Card information</CardTitle>
        <CardDescription>Enter your card information</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <div className="flex flex-col gap-2">
          <Label htmlFor={`${id}-card-number`}>Card number</Label>
          <MaskInput
            id={`${id}-card-number`}
            mask="creditCard"
            placeholder="1234 1234 1234 1234"
            validationMode="onBlur"
            value={cardNumber}
            onValueChange={setCardNumber}
            onValidate={setCardNumberValid}
            invalid={!cardNumberValid}
          />
          {!cardNumberValid && cardNumber && (
            <p className="text-destructive text-sm">
              Please enter a valid credit card number.
            </p>
          )}
        </div>
        <div className="grid grid-cols-2 gap-4">
          <div className="flex flex-col gap-2">
            <Label htmlFor={`${id}-expiry`}>Expiry date</Label>
            <MaskInput
              id={`${id}-expiry`}
              mask="creditCardExpiry"
              placeholder="MM/YY"
              validationMode="onBlur"
              value={expiryDate}
              onValueChange={setExpiryDate}
              onValidate={setExpiryValid}
              invalid={!expiryValid}
            />
            {!expiryValid && expiryDate && (
              <p className="text-destructive text-sm">
                Your card's expiration date is invalid.
              </p>
            )}
          </div>
          <div className="flex flex-col gap-2">
            <Label htmlFor={`${id}-cvc`}>CVC</Label>
            <MaskInput
              id={`${id}-cvc`}
              mask={{
                pattern: "###",
                transform: (value) => value.replace(/[^0-9]/g, ""),
                validate: (value) => value.length === 3,
              }}
              placeholder="123"
              validationMode="onBlur"
              value={cvc}
              onValueChange={setCvc}
              onValidate={setCvcValid}
              invalid={!cvcValid}
            />
            {!cvcValid && cvc && (
              <p className="text-destructive text-sm">CVC must be 3 digits.</p>
            )}
          </div>
        </div>
      </CardContent>
      <CardFooter>
        <Button onClick={onSubmit} className="w-full" disabled={!isFormValid}>
          Submit
        </Button>
      </CardFooter>
    </Card>
  );
}

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="Enter phone number"
                  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="Enter SSN"
                  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="Enter birth date"
                  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="Enter emergency contact"
                  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
creditCardExpiry##/##12/25Credit card expiry date (MM/YY)
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: "###-###-####",
  transform: (value, opts) => value.replace(/[^A-Z0-9]/gi, "").toUpperCase(),
  validate: (value, opts) => value.length === 10,
};

<MaskInput 
  mask={customPattern} 
  placeholder="Enter license plate"
  maskPlaceholder="ABC-1234"
/>

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

Mask Placeholders

Use the maskPlaceholder prop to control when mask format hints are shown. The mask placeholder only appears when the input is focused and the prop is provided.

// Shows mask placeholder when focused
<MaskInput 
  mask="phone" 
  placeholder="Enter phone number"
  maskPlaceholder="(___) ___-____"
/>

// No mask placeholder - just regular placeholder behavior
<MaskInput 
  mask="phone" 
  placeholder="Enter phone number"
/>

API Reference

MaskInput

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

Prop

Type

MaskPattern

Interface for creating custom mask patterns.

Prop

Type

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
creditCardExpiryCredit card expiry date (MM/YY)
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.

Prop

Type

ValidateOptions

Options passed to the validate function for enhanced validation.

Prop

Type

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.