mask-input

PreviousNext
Docs
diceuiui

Preview

Loading preview…
ui/mask-input.tsx
"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,
};

Installation

npx shadcn@latest add @diceui/mask-input

Usage

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