Mask Input
An input component that formats user input with predefined patterns like phone numbers, dates, and credit cards.
"use client";
import * as React from "react";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
interface Input {
phone: string;
date: string;
dollar: string;
euro: string;
creditCard: string;
percentage: string;
}
interface Validation {
phone: boolean;
date: boolean;
dollar: boolean;
euro: boolean;
creditCard: boolean;
percentage: boolean;
}
export function MaskInputDemo() {
const id = React.useId();
const [input, setInput] = React.useState<Input>({
phone: "",
date: "",
dollar: "",
euro: "",
creditCard: "",
percentage: "",
});
const [validation, setValidation] = React.useState<Validation>({
phone: true,
date: true,
dollar: true,
euro: true,
creditCard: true,
percentage: true,
});
const onValueChange = React.useCallback(
(field: keyof Input) => (maskedValue: string) => {
setInput((prev) => ({
...prev,
[field]: maskedValue,
}));
},
[],
);
const onValidate = React.useCallback(
(field: keyof Validation) => (isValid: boolean) => {
setValidation((prev) => ({
...prev,
[field]: isValid,
}));
},
[],
);
return (
<div className="grid w-full gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-phone`}>Phone Number</Label>
<MaskInput
id={`${id}-phone`}
mask="phone"
placeholder="Enter your phone number"
value={input.phone}
onValueChange={onValueChange("phone")}
onValidate={onValidate("phone")}
invalid={!validation.phone}
/>
<p className="text-muted-foreground text-sm">
Enter your phone number with area code
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-date`}>Birth Date</Label>
<MaskInput
id={`${id}-date`}
mask="date"
placeholder="mm/dd/yyyy"
value={input.date}
onValueChange={onValueChange("date")}
onValidate={onValidate("date")}
invalid={!validation.date}
/>
<p className="text-muted-foreground text-sm">Enter your birth date</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-dollar`}>Dollar</Label>
<MaskInput
id={`${id}-dollar`}
mask="currency"
placeholder="$0.00"
value={input.dollar}
onValueChange={onValueChange("dollar")}
onValidate={onValidate("dollar")}
invalid={!validation.dollar}
/>
<p className="text-muted-foreground text-sm">Enter your currency</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-euro`}>Euro (German)</Label>
<MaskInput
id={`${id}-euro`}
mask="currency"
currency="EUR"
locale="de-DE"
placeholder="0,00 €"
value={input.euro}
onValueChange={onValueChange("euro")}
onValidate={onValidate("euro")}
invalid={!validation.euro}
/>
<p className="text-muted-foreground text-sm">Enter your currency</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-creditCard`}>Credit Card</Label>
<MaskInput
id={`${id}-creditCard`}
mask="creditCard"
placeholder="4242 4242 4242 4242"
value={input.creditCard}
onValueChange={onValueChange("creditCard")}
onValidate={onValidate("creditCard")}
invalid={!validation.creditCard}
/>
<p className="text-muted-foreground text-sm">
Enter your credit card number
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-percentage`}>Percentage (0-100%)</Label>
<MaskInput
id={`${id}-percentage`}
mask="percentage"
placeholder="0.00%"
min={0}
max={100}
value={input.percentage}
onValueChange={onValueChange("percentage")}
onValidate={onValidate("percentage")}
invalid={!validation.percentage}
/>
<p className="text-muted-foreground text-sm">
Enter a percentage between 0% and 100%
</p>
</div>
</div>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/mask-input"
Manual
Install the following dependencies:
npm install @radix-ui/react-slot
Copy the refs composition utilities into your lib/compose-refs.ts
file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };
Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
const PAST_YEARS_LIMIT = 120;
const FUTURE_YEARS_LIMIT = 10;
const DEFAULT_CURRENCY = "USD";
const DEFAULT_LOCALE = "en-US";
const NUMERIC_MASK_PATTERNS =
/^(phone|zipCode|zipCodeExtended|ssn|ein|time|date|creditCard)$/;
const CURRENCY_PERCENTAGE_SYMBOLS = /[€$%]/;
const CURRENCY_FALLBACK = "$0.00";
const ZERO_PERCENTAGE = "0.00%";
interface CurrencySymbols {
currency: string;
decimal: string;
group: string;
}
const formattersCache = new Map<string, Intl.NumberFormat>();
const currencyAtEndCache = new Map<string, boolean>();
const currencySymbolsCache = new Map<string, CurrencySymbols>();
const daysInMonthCache = [
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
] as const;
const REGEX_CACHE = {
digitsOnly: /^\d+$/,
nonDigits: /\D/g,
nonAlphaNumeric: /[^A-Z0-9]/gi,
nonNumericDot: /[^0-9.]/g,
nonCurrencyChars: /[^\d.,]/g,
hashPattern: /#/g,
currencyAtEnd: /\d\s*[^\d\s]+$/,
percentageChars: /[^\d.]/g,
phone: /^\d{10}$/,
ssn: /^\d{9}$/,
zipCode: /^\d{5}$/,
zipCodeExtended: /^\d{9}$/,
isbn: /^\d{13}$/,
ein: /^\d{9}$/,
time: /^\d{4}$/,
creditCard: /^\d{15,19}$/,
licensePlate: /^[A-Z0-9]{6}$/,
macAddress: /^[A-F0-9]{12}$/,
currencyValidation: /^\d+(\.\d{1,2})?$/,
ipv4Segment: /^\d{1,3}$/,
} as const;
function getCachedFormatter(
locale: string,
opts: Intl.NumberFormatOptions,
): Intl.NumberFormat {
const {
currency,
minimumFractionDigits = 0,
maximumFractionDigits = 2,
} = opts;
const key = `${locale}|${currency}|${minimumFractionDigits}|${maximumFractionDigits}`;
if (!formattersCache.has(key)) {
try {
formattersCache.set(
key,
new Intl.NumberFormat(locale, {
style: "currency",
currency,
...opts,
}),
);
} catch {
formattersCache.set(
key,
new Intl.NumberFormat(DEFAULT_LOCALE, {
style: "currency",
currency: DEFAULT_CURRENCY,
...opts,
}),
);
}
}
const formatter = formattersCache.get(key);
if (!formatter) {
throw new Error(`Failed to create formatter for ${key}`);
}
return formatter;
}
function getCachedCurrencySymbols(opts: {
locale: string;
currency: string;
}): CurrencySymbols {
const { locale, currency } = opts;
const key = `${locale}|${currency}`;
const cached = currencySymbolsCache.get(key);
if (cached) {
return cached;
}
let currencySymbol = "$";
let decimalSeparator = ".";
let groupSeparator = ",";
try {
const formatter = getCachedFormatter(locale, {
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
const parts = formatter.formatToParts(1234.5);
const currencyPart = parts.find((part) => part.type === "currency");
const decimalPart = parts.find((part) => part.type === "decimal");
const groupPart = parts.find((part) => part.type === "group");
if (currencyPart) currencySymbol = currencyPart.value;
if (decimalPart) decimalSeparator = decimalPart.value;
if (groupPart) groupSeparator = groupPart.value;
} catch {
// Keep defaults
}
const symbols: CurrencySymbols = {
currency: currencySymbol,
decimal: decimalSeparator,
group: groupSeparator,
};
currencySymbolsCache.set(key, symbols);
return symbols;
}
function isCurrencyAtEnd(opts: { locale: string; currency: string }): boolean {
const { locale, currency } = opts;
const key = `${locale}|${currency}`;
const cached = currencyAtEndCache.get(key);
if (cached !== undefined) {
return cached;
}
try {
const formatter = getCachedFormatter(locale, {
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const sample = formatter.format(123);
const result = REGEX_CACHE.currencyAtEnd.test(sample);
currencyAtEndCache.set(key, result);
return result;
} catch {
currencyAtEndCache.set(key, false);
return false;
}
}
function isCurrencyMask(opts: {
mask: MaskPatternKey | MaskPattern | undefined;
pattern?: string;
}): boolean {
const { mask, pattern } = opts;
return (
mask === "currency" ||
Boolean(pattern && (pattern.includes("$") || pattern.includes("€")))
);
}
interface TransformOptions {
currency?: string;
locale?: string;
}
interface ValidateOptions {
min?: number;
max?: number;
}
interface MaskPattern {
pattern: string;
placeholder?: string;
transform?: (value: string, opts?: TransformOptions) => string;
validate?: (value: string, opts?: ValidateOptions) => boolean;
}
type MaskPatternKey =
| "phone"
| "ssn"
| "date"
| "time"
| "creditCard"
| "zipCode"
| "zipCodeExtended"
| "currency"
| "percentage"
| "licensePlate"
| "ipv4"
| "macAddress"
| "isbn"
| "ein";
const MASK_PATTERNS: Record<MaskPatternKey, MaskPattern> = {
phone: {
pattern: "(###) ###-####",
placeholder: "(___) ___-____",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) =>
REGEX_CACHE.phone.test(value.replace(REGEX_CACHE.nonDigits, "")),
},
ssn: {
pattern: "###-##-####",
placeholder: "___-__-____",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) =>
REGEX_CACHE.ssn.test(value.replace(REGEX_CACHE.nonDigits, "")),
},
date: {
pattern: "##/##/####",
placeholder: "mm/dd/yyyy",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) => {
const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
if (cleaned.length !== 8) return false;
const month = parseInt(cleaned.substring(0, 2), 10);
const day = parseInt(cleaned.substring(2, 4), 10);
const year = parseInt(cleaned.substring(4, 8), 10);
const currentYear = new Date().getFullYear();
const minYear = currentYear - PAST_YEARS_LIMIT;
const maxYear = currentYear + FUTURE_YEARS_LIMIT;
if (
month < 1 ||
month > 12 ||
day < 1 ||
year < minYear ||
year > maxYear
)
return false;
const maxDays =
month === 2 &&
((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0)
? 29
: (daysInMonthCache[month - 1] ?? 31);
return day <= maxDays;
},
},
time: {
pattern: "##:##",
placeholder: "hh:mm",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) => {
const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
if (!REGEX_CACHE.time.test(cleaned)) return false;
const hours = parseInt(cleaned.substring(0, 2), 10);
const minutes = parseInt(cleaned.substring(2, 4), 10);
return hours <= 23 && minutes <= 59;
},
},
creditCard: {
pattern: "#### #### #### ####",
placeholder: "____ ____ ____ ____",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) => {
const cleaned = value.replace(REGEX_CACHE.nonDigits, "");
return REGEX_CACHE.creditCard.test(cleaned);
},
},
zipCode: {
pattern: "#####",
placeholder: "_____",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) =>
REGEX_CACHE.zipCode.test(value.replace(REGEX_CACHE.nonDigits, "")),
},
zipCodeExtended: {
pattern: "#####-####",
placeholder: "_____-____",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) =>
REGEX_CACHE.zipCodeExtended.test(
value.replace(REGEX_CACHE.nonDigits, ""),
),
},
currency: {
pattern: "$###,###.##",
placeholder: "$0.00",
transform: (
value,
{ currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = {},
) => {
let localeDecimalSeparator = ".";
try {
const formatter = getCachedFormatter(locale, {
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
const parts = formatter.formatToParts(1234.5);
const decimalPart = parts.find((part) => part.type === "decimal");
if (decimalPart) localeDecimalSeparator = decimalPart.value;
} catch {
// Keep defaults
}
const cleaned = value.replace(REGEX_CACHE.nonCurrencyChars, "");
const dotIndex = cleaned.indexOf(".");
const commaIndex = cleaned.indexOf(",");
let hasDecimalSeparator = false;
let decimalIndex = -1;
if (localeDecimalSeparator === ",") {
const lastCommaIndex = cleaned.lastIndexOf(",");
if (lastCommaIndex !== -1) {
const afterComma = cleaned.substring(lastCommaIndex + 1);
if (afterComma.length <= 2 && /^\d*$/.test(afterComma)) {
hasDecimalSeparator = true;
decimalIndex = lastCommaIndex;
}
}
if (!hasDecimalSeparator && dotIndex !== -1) {
const afterDot = cleaned.substring(dotIndex + 1);
if (afterDot.length <= 2 && /^\d*$/.test(afterDot)) {
hasDecimalSeparator = true;
decimalIndex = dotIndex;
}
}
if (!hasDecimalSeparator && cleaned.length >= 4) {
const match = cleaned.match(/^(\d+)\.(\d{3})(\d{1,2})$/);
if (match) {
const [, beforeDot, thousandsPart, decimalPart] = match;
const integerPart = (beforeDot || "") + (thousandsPart || "");
const result = `${integerPart}.${decimalPart}`;
return result;
}
}
} else {
const lastDotIndex = cleaned.lastIndexOf(".");
if (lastDotIndex !== -1) {
const afterDot = cleaned.substring(lastDotIndex + 1);
if (afterDot.length <= 2 && /^\d*$/.test(afterDot)) {
hasDecimalSeparator = true;
decimalIndex = lastDotIndex;
}
}
if (!hasDecimalSeparator && commaIndex !== -1) {
const afterComma = cleaned.substring(commaIndex + 1);
const looksLikeThousands = commaIndex <= 3 && afterComma.length >= 3;
if (
!looksLikeThousands &&
afterComma.length <= 2 &&
/^\d*$/.test(afterComma)
) {
hasDecimalSeparator = true;
decimalIndex = commaIndex;
}
}
}
if (hasDecimalSeparator && decimalIndex !== -1) {
const beforeDecimal = cleaned
.substring(0, decimalIndex)
.replace(/[.,]/g, "");
const afterDecimal = cleaned
.substring(decimalIndex + 1)
.replace(/[.,]/g, "");
if (afterDecimal === "") {
const result = `${beforeDecimal}.`;
return result;
}
const result = `${beforeDecimal}.${afterDecimal.substring(0, 2)}`;
return result;
}
const digitsOnly = cleaned.replace(/[.,]/g, "");
return digitsOnly;
},
validate: (value) => {
if (!REGEX_CACHE.currencyValidation.test(value)) return false;
const num = parseFloat(value);
return !Number.isNaN(num) && num >= 0;
},
},
percentage: {
pattern: "##.##%",
placeholder: "0.00%",
transform: (value) => {
const cleaned = value.replace(REGEX_CACHE.percentageChars, "");
const parts = cleaned.split(".");
if (parts.length > 2) {
return `${parts[0]}.${parts.slice(1).join("")}`;
}
if (parts[1] && parts[1].length > 2) {
return `${parts[0]}.${parts[1].substring(0, 2)}`;
}
return cleaned;
},
validate: (value, opts = {}) => {
const num = parseFloat(value);
const min = opts.min ?? 0;
const max = opts.max ?? 100;
return !Number.isNaN(num) && num >= min && num <= max;
},
},
licensePlate: {
pattern: "###-###",
placeholder: "ABC-123",
transform: (value) =>
value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(),
validate: (value) => REGEX_CACHE.licensePlate.test(value),
},
ipv4: {
pattern: "###.###.###.###",
placeholder: "192.168.1.1",
transform: (value) => value.replace(REGEX_CACHE.nonNumericDot, ""),
validate: (value) => {
if (value.includes(".")) {
const segments = value.split(".");
if (segments.length > 4) return false;
return segments.every((segment) => {
if (segment === "") return true;
if (!REGEX_CACHE.ipv4Segment.test(segment)) return false;
const num = parseInt(segment, 10);
return num <= 255;
});
} else {
if (!REGEX_CACHE.digitsOnly.test(value)) return false;
if (value.length > 12) return false;
const chunks = [];
for (let i = 0; i < value.length; i += 3) {
chunks.push(value.substring(i, i + 3));
}
if (chunks.length > 4) return false;
return chunks.every((chunk) => {
const num = parseInt(chunk, 10);
return num >= 0 && num <= 255;
});
}
},
},
macAddress: {
pattern: "##:##:##:##:##:##",
placeholder: "00:1B:44:11:3A:B7",
transform: (value) =>
value.replace(REGEX_CACHE.nonAlphaNumeric, "").toUpperCase(),
validate: (value) => REGEX_CACHE.macAddress.test(value),
},
isbn: {
pattern: "###-#-###-#####-#",
placeholder: "978-0-123-45678-9",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) =>
REGEX_CACHE.isbn.test(value.replace(REGEX_CACHE.nonDigits, "")),
},
ein: {
pattern: "##-#######",
placeholder: "12-3456789",
transform: (value) => value.replace(REGEX_CACHE.nonDigits, ""),
validate: (value) =>
REGEX_CACHE.ein.test(value.replace(REGEX_CACHE.nonDigits, "")),
},
};
function applyMask(opts: {
value: string;
pattern: string;
currency?: string;
locale?: string;
mask?: MaskPatternKey | MaskPattern;
}): string {
const { value, pattern, currency, locale, mask } = opts;
const cleanValue = value;
if (pattern.includes("$") || pattern.includes("€") || mask === "currency") {
return applyCurrencyMask({
value: cleanValue,
currency: currency ?? DEFAULT_CURRENCY,
locale: locale ?? DEFAULT_LOCALE,
});
}
if (pattern.includes("%")) {
return applyPercentageMask(cleanValue);
}
if (mask === "ipv4") {
return cleanValue;
}
const maskedChars: string[] = [];
let valueIndex = 0;
for (let i = 0; i < pattern.length && valueIndex < cleanValue.length; i++) {
const patternChar = pattern[i];
const valueChar = cleanValue[valueIndex];
if (patternChar === "#" && valueChar) {
maskedChars.push(valueChar);
valueIndex++;
} else if (patternChar) {
maskedChars.push(patternChar);
}
}
return maskedChars.join("");
}
function applyCurrencyMask(opts: {
value: string;
currency?: string;
locale?: string;
}): string {
const { value, currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = opts;
if (!value) return "";
const {
currency: currencySymbol,
decimal: decimalSeparator,
group: groupSeparator,
} = getCachedCurrencySymbols({ locale, currency });
const normalizedValue = value
.replace(
new RegExp(
`\\${groupSeparator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
"g",
),
"",
)
.replace(decimalSeparator, ".");
const parts = normalizedValue.split(".");
const integerPart = parts[0] ?? "";
const fractionalPart = parts[1] ?? "";
if (!integerPart && !fractionalPart) return "";
const intValue = integerPart ?? "0";
const fracValue = fractionalPart.slice(0, 2);
const num = Number(`${intValue}.${fracValue ?? ""}`);
if (Number.isNaN(num)) {
const cleanedDigits = value.replace(/[^\d]/g, "");
if (!cleanedDigits) return "";
return `${currencySymbol}${cleanedDigits}`;
}
const hasExplicitDecimal =
value.includes(".") || value.includes(decimalSeparator);
try {
const formatter = getCachedFormatter(locale, {
currency,
minimumFractionDigits: fracValue ? fracValue.length : 0,
maximumFractionDigits: 2,
});
const result = formatter.format(num);
if (hasExplicitDecimal && !fracValue) {
if (result.match(/^[^\d\s]+/)) {
const finalResult = result.replace(/(\d)$/, `$1${decimalSeparator}`);
return finalResult;
} else {
const finalResult = result.replace(
/(\d)(\s*)([^\d\s]+)$/,
`$1${decimalSeparator}$2$3`,
);
return finalResult;
}
}
return result;
} catch {
const formattedInt = intValue.replace(
/\B(?=(\d{3})+(?!\d))/g,
groupSeparator,
);
let result = `${currencySymbol}${formattedInt}`;
if (hasExplicitDecimal) {
result += `${decimalSeparator}${fracValue}`;
}
return result;
}
}
function applyPercentageMask(value: string): string {
if (!value) return "";
const parts = value.split(".");
let result = parts[0] ?? "0";
if (value.includes(".")) {
result += `.${(parts[1] ?? "").substring(0, 2)}`;
}
return `${result}%`;
}
function getUnmaskedValue(opts: {
value: string;
currency?: string;
locale?: string;
transform?: (value: string, opts?: TransformOptions) => string;
}): string {
const { value, transform, currency, locale } = opts;
return transform
? transform(value, { currency, locale })
: value.replace(REGEX_CACHE.nonDigits, "");
}
function toUnmaskedIndex(opts: {
masked: string;
pattern: string;
caret: number;
}): number {
const { masked, pattern, caret } = opts;
let idx = 0;
for (let i = 0; i < caret && i < masked.length && i < pattern.length; i++) {
if (pattern[i] === "#") {
idx++;
}
}
return idx;
}
function fromUnmaskedIndex(opts: {
masked: string;
pattern: string;
unmaskedIndex: number;
}): number {
const { masked, pattern, unmaskedIndex } = opts;
let seen = 0;
for (let i = 0; i < masked.length && i < pattern.length; i++) {
if (pattern[i] === "#") {
seen++;
if (seen === unmaskedIndex) {
return i + 1;
}
}
}
return masked.length;
}
type InputElement = React.ComponentRef<"input">;
interface MaskInputProps extends React.ComponentProps<"input"> {
value?: string;
defaultValue?: string;
onValueChange?: (maskedValue: string, unmaskedValue: string) => void;
onValidate?: (isValid: boolean, unmaskedValue: string) => void;
validationMode?: "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all";
mask?: MaskPatternKey | MaskPattern;
currency?: string;
locale?: string;
asChild?: boolean;
invalid?: boolean;
withoutMask?: boolean;
}
function getCurrencyCaretPosition(
newValue: string,
mask: MaskPatternKey | MaskPattern | undefined,
transformOpts: { currency: string; locale: string },
): number {
if (mask === "currency") {
const currencyAtEnd = isCurrencyAtEnd(transformOpts);
if (currencyAtEnd) {
const match = newValue.match(/(\d)\s*([^\d\s]+)$/);
if (match?.[1]) {
return newValue.lastIndexOf(match[1]) + 1;
} else {
return newValue.length;
}
} else {
return newValue.length;
}
} else {
return newValue.length;
}
}
function getPatternCaretPosition(
newValue: string,
maskPattern: MaskPattern,
currentUnmasked: string,
): number {
let position = 0;
let unmaskedCount = 0;
for (let i = 0; i < maskPattern.pattern.length && i < newValue.length; i++) {
if (maskPattern.pattern[i] === "#") {
unmaskedCount++;
if (unmaskedCount <= currentUnmasked.length) {
position = i + 1;
}
}
}
return position;
}
function MaskInput(props: MaskInputProps) {
const {
value: valueProp,
defaultValue,
onValueChange: onValueChangeProp,
onValidate,
onBlur: onBlurProp,
onFocus: onFocusProp,
onKeyDown: onKeyDownProp,
onPaste: onPasteProp,
onCompositionStart: onCompositionStartProp,
onCompositionEnd: onCompositionEndProp,
validationMode = "onChange",
mask,
currency = DEFAULT_CURRENCY,
locale = DEFAULT_LOCALE,
placeholder,
inputMode,
min,
max,
maxLength,
asChild = false,
disabled = false,
invalid = false,
readOnly = false,
required = false,
withoutMask = false,
className,
ref,
...inputProps
} = props;
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
const [focused, setFocused] = React.useState(false);
const [composing, setComposing] = React.useState(false);
const [touched, setTouched] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const composedRef = useComposedRefs(ref, inputRef);
const isControlled = valueProp !== undefined;
const value = isControlled ? valueProp : internalValue;
const maskPattern = React.useMemo(() => {
if (typeof mask === "string") {
return MASK_PATTERNS[mask];
}
return mask;
}, [mask]);
const transformOpts = React.useMemo(
() => ({
currency,
locale,
}),
[currency, locale],
);
const placeholderValue = React.useMemo(() => {
if (withoutMask) return placeholder;
if (placeholder) {
if (focused && maskPattern) {
if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
try {
const formatter = getCachedFormatter(locale, {
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return formatter.format(0);
} catch {
return `${getCachedFormatter(DEFAULT_LOCALE, {
currency: DEFAULT_CURRENCY,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(0)}`;
}
}
if (mask === "percentage" || maskPattern.pattern.includes("%")) {
return ZERO_PERCENTAGE;
}
return maskPattern?.placeholder ?? placeholder;
}
return placeholder;
}
if (focused && maskPattern) {
if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
try {
const formatter = getCachedFormatter(locale, {
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return formatter.format(0);
} catch {
return CURRENCY_FALLBACK;
}
}
if (mask === "percentage" || maskPattern.pattern.includes("%")) {
return ZERO_PERCENTAGE;
}
return maskPattern?.placeholder;
}
return undefined;
}, [placeholder, withoutMask, maskPattern, focused, mask, currency, locale]);
const displayValue = React.useMemo(() => {
if (withoutMask || !maskPattern || !value) return value ?? "";
const unmasked = getUnmaskedValue({
value,
transform: maskPattern.transform,
...transformOpts,
});
return applyMask({
value: unmasked,
pattern: maskPattern.pattern,
...transformOpts,
mask,
});
}, [value, maskPattern, withoutMask, transformOpts, mask]);
const tokenCount = React.useMemo(() => {
if (!maskPattern || CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern))
return undefined;
return maskPattern.pattern.match(REGEX_CACHE.hashPattern)?.length ?? 0;
}, [maskPattern]);
const calculatedMaxLength = tokenCount
? maskPattern?.pattern.length
: maxLength;
const calculatedInputMode = React.useMemo(() => {
if (inputMode) return inputMode;
if (!maskPattern) return undefined;
if (mask === "currency" || mask === "percentage" || mask === "ipv4") {
return "decimal";
}
if (typeof mask === "string" && NUMERIC_MASK_PATTERNS.test(mask)) {
return "numeric";
}
return undefined;
}, [maskPattern, mask, inputMode]);
const shouldValidate = React.useCallback(
(trigger: "change" | "blur") => {
if (!onValidate || !maskPattern?.validate) return false;
switch (validationMode) {
case "onChange":
return trigger === "change";
case "onBlur":
return trigger === "blur";
case "onSubmit":
return false;
case "onTouched":
return touched ? trigger === "change" : trigger === "blur";
case "all":
return true;
default:
return trigger === "change";
}
},
[onValidate, maskPattern, validationMode, touched],
);
const validationOpts = React.useMemo(
() => ({
min: typeof min === "string" ? parseFloat(min) : min,
max: typeof max === "string" ? parseFloat(max) : max,
}),
[min, max],
);
const onInputValidate = React.useCallback(
(unmaskedValue: string) => {
if (onValidate && maskPattern?.validate) {
const isValid = maskPattern.validate(unmaskedValue, validationOpts);
onValidate(isValid, unmaskedValue);
}
},
[onValidate, maskPattern?.validate, validationOpts],
);
const onValueChange = React.useCallback(
(event: React.ChangeEvent<InputElement>) => {
const inputValue = event.target.value;
let newValue = inputValue;
let unmaskedValue = inputValue;
if (composing) {
if (!isControlled) setInternalValue(inputValue);
return;
}
if (withoutMask || !maskPattern) {
if (!isControlled) setInternalValue(inputValue);
if (shouldValidate("change")) onValidate?.(true, inputValue);
onValueChangeProp?.(inputValue, inputValue);
return;
}
if (maskPattern) {
unmaskedValue = getUnmaskedValue({
value: inputValue,
transform: maskPattern.transform,
...transformOpts,
});
newValue = applyMask({
value: unmaskedValue,
pattern: maskPattern.pattern,
...transformOpts,
mask,
});
if (inputRef.current && newValue !== inputValue) {
const inputElement = inputRef.current;
if (!(inputElement instanceof HTMLInputElement)) return;
inputElement.value = newValue;
const currentUnmasked = getUnmaskedValue({
value: newValue,
transform: maskPattern.transform,
...transformOpts,
});
let newCursorPosition: number;
if (CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern)) {
newCursorPosition = getCurrencyCaretPosition(
newValue,
mask,
transformOpts,
);
} else {
newCursorPosition = getPatternCaretPosition(
newValue,
maskPattern,
currentUnmasked,
);
}
if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
if (mask === "currency") {
const currencyAtEnd = isCurrencyAtEnd(transformOpts);
if (!currencyAtEnd) {
newCursorPosition = Math.max(1, newCursorPosition);
}
} else {
newCursorPosition = Math.max(1, newCursorPosition);
}
} else if (maskPattern.pattern.includes("%")) {
newCursorPosition = Math.min(
newValue.length - 1,
newCursorPosition,
);
}
newCursorPosition = Math.min(newCursorPosition, newValue.length);
inputElement.setSelectionRange(newCursorPosition, newCursorPosition);
}
}
if (!isControlled) {
setInternalValue(newValue);
}
if (shouldValidate("change")) {
onInputValidate(unmaskedValue);
}
onValueChangeProp?.(newValue, unmaskedValue);
},
[
maskPattern,
isControlled,
onValueChangeProp,
onValidate,
onInputValidate,
composing,
shouldValidate,
withoutMask,
transformOpts,
mask,
],
);
const onFocus = React.useCallback(
(event: React.FocusEvent<InputElement>) => {
onFocusProp?.(event);
if (event.defaultPrevented) return;
setFocused(true);
},
[onFocusProp],
);
const onBlur = React.useCallback(
(event: React.FocusEvent<InputElement>) => {
onBlurProp?.(event);
if (event.defaultPrevented) return;
setFocused(false);
if (!touched) {
setTouched(true);
}
if (shouldValidate("blur")) {
const currentValue = event.target.value;
const unmaskedValue = maskPattern
? getUnmaskedValue({
value: currentValue,
transform: maskPattern.transform,
...transformOpts,
})
: currentValue;
onInputValidate(unmaskedValue);
}
},
[
onBlurProp,
touched,
shouldValidate,
onInputValidate,
maskPattern,
transformOpts,
],
);
const onCompositionStart = React.useCallback(
(event: React.CompositionEvent<InputElement>) => {
onCompositionStartProp?.(event);
if (event.defaultPrevented) return;
setComposing(true);
},
[onCompositionStartProp],
);
const onCompositionEnd = React.useCallback(
(e: React.CompositionEvent<InputElement>) => {
onCompositionEndProp?.(e);
if (e.defaultPrevented) return;
setComposing(false);
const inputElement = inputRef.current;
if (!inputElement) return;
if (!(inputElement instanceof HTMLInputElement)) return;
const inputValue = inputElement.value;
if (!maskPattern || withoutMask) {
if (!isControlled) setInternalValue(inputValue);
if (shouldValidate("change")) onValidate?.(true, inputValue);
onValueChangeProp?.(inputValue, inputValue);
return;
}
const unmasked = getUnmaskedValue({
value: inputValue,
transform: maskPattern.transform,
...transformOpts,
});
const masked = applyMask({
value: unmasked,
pattern: maskPattern.pattern,
...transformOpts,
mask,
});
if (!isControlled) setInternalValue(masked);
if (shouldValidate("change")) onInputValidate(unmasked);
onValueChangeProp?.(masked, unmasked);
},
[
onCompositionEndProp,
maskPattern,
withoutMask,
isControlled,
shouldValidate,
onValidate,
onValueChangeProp,
transformOpts,
mask,
onInputValidate,
],
);
const onPaste = React.useCallback(
(event: React.ClipboardEvent<InputElement>) => {
onPasteProp?.(event);
if (event.defaultPrevented) return;
if (withoutMask || !maskPattern) return;
if (mask === "ipv4") return;
const target = event.target as InputElement;
if (!(target instanceof HTMLInputElement)) return;
const pastedData = event.clipboardData.getData("text");
if (!pastedData) return;
event.preventDefault();
const currentValue = target.value;
const selectionStart = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
const beforeSelection = currentValue.slice(0, selectionStart);
const afterSelection = currentValue.slice(selectionEnd);
const newInputValue = beforeSelection + pastedData + afterSelection;
const unmasked = getUnmaskedValue({
value: newInputValue,
transform: maskPattern.transform,
...transformOpts,
});
const newMaskedValue = applyMask({
value: unmasked,
pattern: maskPattern.pattern,
...transformOpts,
mask,
});
target.value = newMaskedValue;
if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {
const currencyAtEnd = isCurrencyAtEnd(transformOpts);
const caret = currencyAtEnd
? newMaskedValue.search(/\s*[^\d\s]+$/)
: newMaskedValue.length;
target.setSelectionRange(caret, caret);
return;
}
if (maskPattern.pattern.includes("%")) {
target.setSelectionRange(
newMaskedValue.length - 1,
newMaskedValue.length - 1,
);
return;
}
let newCursorPosition = newMaskedValue.length;
try {
const unmaskedCount = unmasked.length;
let position = 0;
let count = 0;
for (
let i = 0;
i < maskPattern.pattern.length && i < newMaskedValue.length;
i++
) {
if (maskPattern.pattern[i] === "#") {
count++;
if (count <= unmaskedCount) {
position = i + 1;
}
}
}
newCursorPosition = position;
} catch {
// fallback to end
}
target.setSelectionRange(newCursorPosition, newCursorPosition);
if (!isControlled) setInternalValue(newMaskedValue);
if (shouldValidate("change")) onInputValidate(unmasked);
onValueChangeProp?.(newMaskedValue, unmasked);
},
[
onPasteProp,
withoutMask,
maskPattern,
mask,
transformOpts,
isControlled,
shouldValidate,
onInputValidate,
onValueChangeProp,
],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<InputElement>) => {
onKeyDownProp?.(event);
if (event.defaultPrevented) return;
if (withoutMask || !maskPattern) return;
if (mask === "ipv4") return;
if (event.key === "Backspace") {
const target = event.target as InputElement;
if (!(target instanceof HTMLInputElement)) return;
const cursorPosition = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
const currentValue = target.value;
if (
mask === "currency" ||
mask === "percentage" ||
maskPattern.pattern.includes("$") ||
maskPattern.pattern.includes("€") ||
maskPattern.pattern.includes("%")
) {
return;
}
if (cursorPosition !== selectionEnd) {
return;
}
if (cursorPosition > 0) {
const charBeforeCursor = currentValue[cursorPosition - 1];
const isLiteral = maskPattern.pattern[cursorPosition - 1] !== "#";
if (charBeforeCursor && isLiteral) {
event.preventDefault();
const unmaskedIndex = toUnmaskedIndex({
masked: currentValue,
pattern: maskPattern.pattern,
caret: cursorPosition,
});
if (unmaskedIndex > 0) {
const currentUnmasked = getUnmaskedValue({
value: currentValue,
transform: maskPattern.transform,
...transformOpts,
});
const nextUnmasked =
currentUnmasked.slice(0, unmaskedIndex - 1) +
currentUnmasked.slice(unmaskedIndex);
const nextMasked = applyMask({
value: nextUnmasked,
pattern: maskPattern.pattern,
...transformOpts,
mask,
});
target.value = nextMasked;
const nextCaret = fromUnmaskedIndex({
masked: nextMasked,
pattern: maskPattern.pattern,
unmaskedIndex: unmaskedIndex - 1,
});
target.setSelectionRange(nextCaret, nextCaret);
onValueChangeProp?.(nextMasked, nextUnmasked);
}
return;
}
}
}
if (event.key === "Delete") {
const target = event.target as InputElement;
if (!(target instanceof HTMLInputElement)) return;
const cursorPosition = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
const currentValue = target.value;
if (
mask === "currency" ||
mask === "percentage" ||
maskPattern.pattern.includes("$") ||
maskPattern.pattern.includes("€") ||
maskPattern.pattern.includes("%")
) {
return;
}
if (cursorPosition !== selectionEnd) {
return;
}
if (cursorPosition < currentValue.length) {
const charAtCursor = currentValue[cursorPosition];
const isLiteral = maskPattern.pattern[cursorPosition] !== "#";
if (charAtCursor && isLiteral) {
event.preventDefault();
const unmaskedIndex = toUnmaskedIndex({
masked: currentValue,
pattern: maskPattern.pattern,
caret: cursorPosition,
});
const currentUnmasked = getUnmaskedValue({
value: currentValue,
transform: maskPattern.transform,
...transformOpts,
});
if (unmaskedIndex < currentUnmasked.length) {
const nextUnmasked =
currentUnmasked.slice(0, unmaskedIndex) +
currentUnmasked.slice(unmaskedIndex + 1);
const nextMasked = applyMask({
value: nextUnmasked,
pattern: maskPattern.pattern,
...transformOpts,
mask,
});
target.value = nextMasked;
const nextCaret = fromUnmaskedIndex({
masked: nextMasked,
pattern: maskPattern.pattern,
unmaskedIndex: unmaskedIndex,
});
target.setSelectionRange(nextCaret, nextCaret);
onValueChangeProp?.(nextMasked, nextUnmasked);
}
return;
}
}
}
},
[
maskPattern,
onKeyDownProp,
onValueChangeProp,
transformOpts,
mask,
withoutMask,
],
);
const InputPrimitive = asChild ? Slot : "input";
return (
<InputPrimitive
aria-invalid={invalid}
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
data-required={required ? "" : undefined}
data-slot="mask-input"
{...inputProps}
className={cn(
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
placeholder={placeholderValue}
ref={composedRef}
value={displayValue}
disabled={disabled}
maxLength={calculatedMaxLength}
readOnly={readOnly}
required={required}
inputMode={calculatedInputMode}
min={min}
max={max}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
onPaste={onPaste}
onChange={onValueChange}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
);
}
export {
MaskInput,
//
MASK_PATTERNS,
//
applyMask,
applyCurrencyMask,
applyPercentageMask,
getUnmaskedValue,
toUnmaskedIndex,
fromUnmaskedIndex,
//
type MaskPattern,
type MaskInputProps,
};
Layout
Import and use the component directly.
import { MaskInput } from "@/components/ui/mask-input";
<MaskInput
mask="phone"
placeholder="Enter phone number"
onValueChange={(masked, unmasked) => {
console.log('Masked:', masked); // "(555) 123-4567"
console.log('Unmasked:', unmasked); // "5551234567"
}}
/>
Features
- Smart cursor positioning - Cursor stays in the correct position during typing and pasting
- Paste support - Intelligently handles pasted content with proper formatting
- Built-in patterns - Common formats like phone, SSN, date, credit card, etc.
- Custom patterns - Create your own mask patterns with validation
- TypeScript support - Full type safety with IntelliSense
- Accessibility - ARIA attributes and keyboard navigation
- Form integration - Works seamlessly with form libraries
- Composition support - Use
asChild
prop to render as a different component using Radix Slot
Examples
Built-in Patterns
Use predefined mask patterns for common input formats.
"use client";
import * as React from "react";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
export function MaskInputPatternsDemo() {
const [values, setValues] = React.useState({
phone: "",
ssn: "",
date: "",
time: "",
creditCard: "",
zipCode: "",
});
const handleValueChange =
(field: keyof typeof values) => (maskedValue: string) => {
setValues((prev) => ({
...prev,
[field]: maskedValue,
}));
};
return (
<div className="grid w-full max-w-2xl grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<MaskInput
id="phone"
mask="phone"
value={values.phone}
onValueChange={handleValueChange("phone")}
placeholder="(555) 123-4567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ssn">Social Security Number</Label>
<MaskInput
id="ssn"
mask="ssn"
value={values.ssn}
onValueChange={handleValueChange("ssn")}
placeholder="123-45-6789"
/>
</div>
<div className="space-y-2">
<Label htmlFor="date">Date</Label>
<MaskInput
id="date"
mask="date"
value={values.date}
onValueChange={handleValueChange("date")}
placeholder="mm/dd/yyyy"
/>
</div>
<div className="space-y-2">
<Label htmlFor="time">Time</Label>
<MaskInput
id="time"
mask="time"
value={values.time}
onValueChange={handleValueChange("time")}
placeholder="hh:mm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="creditCard">Credit Card</Label>
<MaskInput
id="creditCard"
mask="creditCard"
value={values.creditCard}
onValueChange={handleValueChange("creditCard")}
placeholder="1234 5678 9012 3456"
/>
</div>
<div className="space-y-2">
<Label htmlFor="zipCode">ZIP Code</Label>
<MaskInput
id="zipCode"
mask="zipCode"
value={values.zipCode}
onValueChange={handleValueChange("zipCode")}
placeholder="12345"
/>
</div>
</div>
);
}
Currency Formatting
The currency mask uses the Intl.NumberFormat
API for localization and currency formatting.
// Default USD formatting
<MaskInput mask="currency" />
// Euro formatting with German locale
<MaskInput
mask="currency"
currency="EUR"
locale="de-DE"
/>
// Japanese Yen formatting
<MaskInput
mask="currency"
currency="JPY"
locale="ja-JP"
/>
// British Pound formatting
<MaskInput
mask="currency"
currency="GBP"
locale="en-GB"
/>
With custom patterns
Create custom mask patterns for specific formatting needs.
"use client";
import * as React from "react";
import { z } from "zod";
import { Label } from "@/components/ui/label";
import { MaskInput, type MaskPattern } from "@/components/ui/mask-input";
// Custom license plate pattern (e.g., ABC-1234)
const licensePattern: MaskPattern = {
pattern: "###-####",
placeholder: "ABC-1234",
transform: (value) => value.replace(/[^A-Z0-9]/gi, "").toUpperCase(),
validate: (value) => {
const cleaned = value.replace(/[^A-Z0-9]/gi, "").toUpperCase();
return cleaned.length === 7 && /^[A-Z]{3}[0-9]{4}$/.test(cleaned);
},
};
// Custom product code pattern (e.g., PRD-ABC-123)
const productCodePattern: MaskPattern = {
pattern: "###-###-###",
placeholder: "PRD-ABC-123",
transform: (value) => {
const cleaned = value.replace(/[^A-Z0-9]/gi, "").toUpperCase();
// If empty or just partial PRD, allow it to be empty
if (cleaned.length === 0) {
return "";
}
// If user is typing and it doesn't start with PRD, prepend it
// But only if they have more than just partial PRD characters
if (!cleaned.startsWith("PRD")) {
// If user typed partial PRD (like "P" or "PR"), don't auto-complete
if (cleaned.length <= 2 && "PRD".startsWith(cleaned)) {
return cleaned;
}
// Otherwise, prepend PRD to their input
return `PRD${cleaned}`;
}
// If it already starts with PRD, keep as is
return cleaned;
},
validate: (value) => {
const cleaned = value.replace(/[^A-Z0-9]/gi, "").toUpperCase();
return cleaned.length === 9 && cleaned.startsWith("PRD");
},
};
export function MaskInputCustomDemo() {
const [licenseValue, setLicenseValue] = React.useState("");
const [productCodeValue, setProductCodeValue] = React.useState("");
const [isLicenseValid, setIsLicenseValid] = React.useState(true);
const [isProductCodeValid, setIsProductCodeValid] = React.useState(true);
return (
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex flex-col gap-2">
<Label htmlFor="license">License plate</Label>
<MaskInput
id="license"
mask={licensePattern}
value={licenseValue}
onValueChange={setLicenseValue}
placeholder="ABC-1234"
invalid={!isLicenseValid}
onValidate={setIsLicenseValid}
/>
<p className="text-muted-foreground text-sm">
Enter license plate (3 letters, 4 numbers)
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product">Product code</Label>
<MaskInput
id="product"
mask={productCodePattern}
value={productCodeValue}
onValueChange={setProductCodeValue}
placeholder="PRD-ABC-123"
invalid={!isProductCodeValid}
onValidate={setIsProductCodeValid}
/>
<p className="text-muted-foreground text-sm">
Enter product code (PRD-XXX-XXX format)
</p>
</div>
</div>
);
}
With validation modes
Control when validation occurs with different validation modes, similar to react-hook-form.
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { MaskInput } from "@/components/ui/mask-input";
const modes = [
{
label: "onChange",
description: "Validates on every keystroke",
value: "onChange" as const,
},
{
label: "onBlur",
description: "Validates when field loses focus",
value: "onBlur" as const,
},
{
label: "onTouched",
description: "Validates after first blur, then on change",
value: "onTouched" as const,
},
{
label: "onSubmit",
description: "Validates only on form submission",
value: "onSubmit" as const,
},
];
export function MaskInputValidationModesDemo() {
const [validationStates, setValidationStates] = React.useState({
onChange: { isValid: true, message: "" },
onBlur: { isValid: true, message: "" },
onTouched: { isValid: true, message: "" },
onSubmit: { isValid: true, message: "" },
});
const [values, setValues] = React.useState({
onChange: "",
onBlur: "",
onTouched: "",
onSubmit: "",
});
const [submitAttempted, setSubmitAttempted] = React.useState(false);
const onValidate = React.useCallback(
(mode: keyof typeof validationStates) =>
(isValid: boolean, unmaskedValue: string) => {
const message = isValid
? `✓ Valid (${unmaskedValue.length}/10)`
: `✗ Invalid (${unmaskedValue.length}/10)`;
setValidationStates((prev) => ({
...prev,
[mode]: { isValid, message },
}));
},
[],
);
const onValueChange = React.useCallback(
(mode: keyof typeof values) =>
(_maskedValue: string, unmaskedValue: string) => {
setValues((prev) => ({
...prev,
[mode]: unmaskedValue,
}));
},
[],
);
const onSubmit = React.useCallback(
(event: React.FormEvent) => {
event.preventDefault();
setSubmitAttempted(true);
const unmaskedValue = values.onSubmit;
const isValid = unmaskedValue.length === 10;
const message = isValid
? `✓ Valid (${unmaskedValue.length}/10)`
: `✗ Invalid (${unmaskedValue.length}/10)`;
setValidationStates((prev) => ({
...prev,
onSubmit: { isValid, message },
}));
},
[values.onSubmit],
);
return (
<div className="grid w-full gap-4 sm:grid-cols-2">
{modes.map((mode) => (
<ValidationModeCard
key={mode.value}
mode={mode}
value={values[mode.value]}
validationState={validationStates[mode.value]}
onValueChange={onValueChange(mode.value)}
onValidate={onValidate(mode.value)}
onSubmit={mode.value === "onSubmit" ? onSubmit : undefined}
submitAttempted={submitAttempted}
/>
))}
</div>
);
}
interface ValidationModeCardProps {
mode: (typeof modes)[number];
value: string;
validationState: { isValid: boolean; message: string };
onValueChange: (maskedValue: string, unmaskedValue: string) => void;
onValidate: (isValid: boolean, unmaskedValue: string) => void;
onSubmit?: (event: React.FormEvent) => void;
submitAttempted: boolean;
}
function ValidationModeCard({
mode,
value,
validationState,
onValueChange,
onValidate,
onSubmit,
submitAttempted,
}: ValidationModeCardProps) {
const inputContent = (
<div className="flex flex-col gap-1">
<Label htmlFor={`phone-${mode.value}`} className="sr-only">
Phone Number
</Label>
<MaskInput
id={`phone-${mode.value}`}
mask="phone"
validationMode={mode.value}
value={value}
onValueChange={onValueChange}
onValidate={onValidate}
placeholder="(555) 123-4567"
invalid={!validationState.isValid}
className="h-8 text-sm"
/>
</div>
);
return (
<div className="flex flex-col gap-3 rounded-md bg-card p-4">
<div className="flex flex-col gap-1">
<h4 className="font-medium text-xs">{mode.label}</h4>
<p className="text-muted-foreground text-xs leading-tight">
{mode.description}
</p>
</div>
{onSubmit ? (
<form onSubmit={onSubmit} className="flex flex-col gap-2">
{inputContent}
<Button type="submit" size="sm" className="h-7 text-xs">
Submit
</Button>
</form>
) : (
inputContent
)}
<div className="flex items-center gap-1">
<Badge
variant={validationState.isValid ? "default" : "destructive"}
className="h-5 px-1.5 text-xs"
>
{validationState.isValid ? "Valid" : "Invalid"}
</Badge>
<span className="text-muted-foreground text-xs">
{validationState.message ||
(mode.value === "onSubmit" && !submitAttempted
? "Click 'Submit' to check..."
: "Start typing to see validation...")}
</span>
</div>
</div>
);
}
With form
Integrate masked inputs with form validation using react-hook-form.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { MaskInput } from "@/components/ui/mask-input";
const formSchema = z.object({
phone: z.string().min(10, "Phone number must be at least 10 digits"),
ssn: z.string().min(9, "SSN must be 9 digits"),
birthDate: z.string().min(8, "Birth date is required"),
emergencyContact: z.string().min(10, "Emergency contact is required"),
});
type FormSchema = z.infer<typeof formSchema>;
export function MaskInputFormDemo() {
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
phone: "",
ssn: "",
birthDate: "",
emergencyContact: "",
},
});
function onSubmit(values: FormSchema) {
toast.success("Form submitted successfully!");
console.log(values);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-6 md:grid-cols-2"
>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone Number</FormLabel>
<FormControl>
<MaskInput
mask="phone"
value={field.value}
onValueChange={(_maskedValue, unmaskedValue) => {
field.onChange(unmaskedValue);
}}
placeholder="(555) 123-4567"
invalid={!!form.formState.errors.phone}
/>
</FormControl>
<FormDescription>Enter your primary phone number</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ssn"
render={({ field }) => (
<FormItem>
<FormLabel>Social Security Number</FormLabel>
<FormControl>
<MaskInput
mask="ssn"
value={field.value}
onValueChange={(_maskedValue, unmaskedValue) => {
field.onChange(unmaskedValue);
}}
placeholder="123-45-6789"
invalid={!!form.formState.errors.ssn}
/>
</FormControl>
<FormDescription>
Enter your social security number
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="birthDate"
render={({ field }) => (
<FormItem>
<FormLabel>Birth Date</FormLabel>
<FormControl>
<MaskInput
mask="date"
value={field.value}
onValueChange={(_maskedValue, unmaskedValue) => {
field.onChange(unmaskedValue);
}}
placeholder="mm/dd/yyyy"
invalid={!!form.formState.errors.birthDate}
/>
</FormControl>
<FormDescription>Enter your date of birth</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emergencyContact"
render={({ field }) => (
<FormItem>
<FormLabel>Emergency Contact</FormLabel>
<FormControl>
<MaskInput
mask="phone"
value={field.value}
onValueChange={(_maskedValue, unmaskedValue) => {
field.onChange(unmaskedValue);
}}
placeholder="(555) 987-6543"
invalid={!!form.formState.errors.emergencyContact}
/>
</FormControl>
<FormDescription>
Enter emergency contact phone number
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-end gap-2 md:col-span-2">
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit">Submit</Button>
</div>
</form>
</Form>
);
}
Built-in Mask Patterns
The component includes several predefined mask patterns:
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 |
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: "###-###-####",
placeholder: "ABC-123-4567",
transform: (value, opts) => value.replace(/[^A-Z0-9]/gi, "").toUpperCase(),
validate: (value, opts) => value.length === 10,
};
<MaskInput mask={customPattern} />
API Reference
MaskInput
The main masked input component that handles formatting and user input.
Prop | Type | Default |
---|---|---|
value? | string | - |
defaultValue? | string | - |
onValueChange? | ((maskedValue: string, unmaskedValue: string) => void) | - |
onValidate? | ((isValid: boolean, unmaskedValue: string) => void) | - |
validationMode? | ValidationMode | - |
mask? | MaskPatternKey | MaskPattern | - |
currency? | string | - |
locale? | string | - |
asChild? | boolean | false |
invalid? | boolean | - |
withoutMask? | boolean | - |
MaskPattern
Interface for creating custom mask patterns.
Prop | Type | Default |
---|---|---|
pattern | string | - |
placeholder? | string | - |
transform? | ((value: string, opts?: TransformOptions | undefined) => string) | - |
validate? | ((value: string, opts?: ValidateOptions | undefined) => boolean) | - |
MaskPatternKey
Predefined mask pattern keys for common input formats.
Pattern | Description |
---|---|
phone | US phone number |
ssn | Social Security Number |
date | Date (MM/DD/YYYY) |
time | Time (HH:MM) |
creditCard | Credit card number |
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.
Prop | Type | Default |
---|---|---|
currency? | string | - |
locale? | string | - |
ValidateOptions
Options passed to the validate function for enhanced validation.
Prop | Type | Default |
---|---|---|
min? | number | - |
max? | number | - |
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. |