Maksud 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://maksud.dev/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;
}): number {
  const { newValue, mask, transformOpts } = opts;
 
  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;
}): number {
  const { newValue, maskPattern, currentUnmasked } = opts;
  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;
}
 
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;
          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(
    (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];
 
          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"
  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.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/mask-input.ts",
  "name": "MaskInputProps"
}

MaskPattern

Interface for creating custom mask patterns.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/mask-input.ts",
  "name": "MaskPattern"
}

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.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/mask-input.ts",
  "name": "TransformOptions"
}

ValidateOptions

Options passed to the validate function for enhanced validation.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/mask-input.ts",
  "name": "ValidateOptions"
}

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.