Mask Input
An input component that formats user input with predefined patterns like phone numbers, dates, and credit cards.
"use client";
import * as React from "react";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
interface Input {
phone: string;
date: string;
dollar: string;
euro: string;
creditCard: string;
percentage: string;
}
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-slotCopy 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
asChildprop 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:
| Pattern | Format | Example | Description |
|---|---|---|---|
phone | (###) ###-#### | (555) 123-4567 | US phone number |
ssn | ###-##-#### | 123-45-6789 | Social Security Number |
date | ##/##/#### | 12/25/2023 | Date (MM/DD/YYYY) |
time | ##:## | 14:30 | Time (HH:MM) |
creditCard | #### #### #### #### | 1234 5678 9012 3456 | Credit card number |
creditCardExpiry | ##/## | 12/25 | Credit card expiry date (MM/YY) |
zipCode | ##### | 12345 | US ZIP code |
zipCodeExtended | #####-#### | 12345-6789 | US ZIP+4 code |
currency | Dynamic | $1,234.56 | Currency formatting using Intl.NumberFormat |
percentage | ##.##% | 12.34% | Percentage with decimals |
licensePlate | ###-### | ABC-123 | License plate format |
ipv4 | ###.###.###.### | 192.168.1.1 | IPv4 address |
macAddress | ##:##:##:##:##:## | 00:1B:44:11:3A:B7 | MAC address |
isbn | ###-#-###-#####-# | 978-0-123-45678-9 | ISBN-13 book identifier |
ein | ##-####### | 12-3456789 | Employer 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.
| Pattern | Description |
|---|---|
phone | US phone number |
ssn | Social Security Number |
date | Date (MM/DD/YYYY) |
time | Time (HH:MM) |
creditCard | Credit card number |
creditCardExpiry | Credit card expiry date (MM/YY) |
zipCode | US ZIP code |
zipCodeExtended | US ZIP+4 code |
currency | Currency formatting using Intl.NumberFormat |
percentage | Percentage with decimals |
licensePlate | License plate format |
ipv4 | IPv4 address |
macAddress | MAC address |
isbn | ISBN-13 book identifier |
ein | Employer 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 Attribute | Value |
|---|---|
[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
| Key | Description |
|---|---|
| Tab | Moves focus to or away from the input. |
| Shift + Tab | Moves focus to the previous focusable element. |
| Backspace | Removes the previous character, intelligently handling mask characters. |
| Delete | Removes the next character. |
| Ctrl + VCmd + V | Pastes content with intelligent mask formatting. |
| Ctrl + ACmd + A | Selects all input content. |