Text input with format masking — phone, card, currency, and custom '#' digit masks — returning raw and formatted values with label/helper/error/success states
components/ui/masked-input/masked-input.tsx@lumioui/core@latestreact-native-reanimated@*react-native-svg@*expo-haptics@*Live preview — the real component, driven by the props below.
import { MaskedInput } from "@/components/ui/masked-input/masked-input";
export function Example() {
return (
<MaskedInput />
);
} import { forwardRef, useEffect, useRef, useState } from "react";
import { Text, TextInput, View, type TextInputProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedProps,
withSpring,
withTiming,
withSequence,
} from "react-native-reanimated";
import Svg from "react-native-svg";
import { cn, spring, timing, useHaptic, useReducedMotion, useTheme, AnimatedPath } from "@lumioui/core";
/**
* MaskedInput — PRO component. A TextInput that applies a format mask as the
* user types. Built-in presets for phone "(###) ###-####", card
* "#### #### #### ####", and currency; or pass a custom `mask` where
* `#` is a digit placeholder. `onChangeText` receives the raw digits plus
* the formatted string. Expo Go compatible.
*/
export type MaskPreset = "phone" | "card" | "currency";
const SUCCESS_PATH = "M5 13l4 4L19 7";
/** Upper bound of the check path length — used for the dasharray trick. */
const CHECK_LEN = 24;
/** Error/helper text entrance — slightly slower than press feedback so the message reads as arriving, not flashing. */
const MSG_ENTER = { duration: 180 };
/** Success check draw-on — long enough to register as a deliberate mark, not a flicker. */
const CHECK_DRAW = { duration: 220 };
/** BeUI-style error shake segment — matches Input's choreography (0 → -6 → 6 → -4 → 4 → -2 → 0 over ~450ms). */
const SHAKE_SEG = { duration: 75 };
const PRESETS: Record<Exclude<MaskPreset, "currency">, string> = {
phone: "(###) ###-####",
card: "#### #### #### ####",
};
export interface MaskedInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "className"> {
/** Formatted value (controlled). Omit with `defaultValue` for uncontrolled. */
value?: string;
/** Initial formatted value (uncontrolled). */
defaultValue?: string;
/** Called with (raw, formatted). */
onChangeText?: (raw: string, formatted: string) => void;
/** Custom mask — `#` marks a digit slot. Overrides `preset`. */
mask?: string;
/** Built-in mask preset. Default "phone" when neither mask nor preset given. */
preset?: MaskPreset;
/** Currency symbol prefix. Default "$". */
currencySymbol?: string;
label?: string;
helperText?: string;
/** Error message — switches input to destructive styling. */
error?: string;
/** Shows an animated success check on the right edge (suppressed while `error` is set). */
success?: boolean;
/** Reserve one helper line even when empty — prevents layout shift on error. */
reserveHelperLine?: boolean;
className?: string;
inputClassName?: string;
}
function digitsOnly(text: string): string {
return text.replace(/\D/g, "");
}
/** Apply a `#`-digit mask to raw digits. */
export function applyMask(mask: string, raw: string): string {
let out = "";
let i = 0;
for (const ch of mask) {
if (i >= raw.length) break;
if (ch === "#") {
out += raw[i];
i += 1;
} else {
out += ch;
}
}
return out;
}
function formatCurrency(raw: string, symbol: string): string {
if (!raw) return "";
const cents = parseInt(raw, 10);
const value = cents / 100;
return `${symbol}${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
export const MaskedInput = forwardRef<TextInput, MaskedInputProps>(
function MaskedInput(
{
value,
defaultValue,
onChangeText,
mask,
preset = "phone",
currencySymbol = "$",
label,
helperText,
error,
success = false,
reserveHelperLine = false,
className,
inputClassName,
editable = true,
maxLength: maxLengthProp,
onFocus,
onBlur,
...props
},
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const [focused, setFocused] = useState(false);
const hasError = Boolean(error);
const showSuccess = success && !error;
const reduceMotion = useReducedMotion();
const describedBy = error ?? helperText;
// Uncontrolled state holds the formatted string so the mask applies
// even when no `value` is passed; controlled mode wins when it is.
const [internal, setInternal] = useState(value ?? defaultValue ?? "");
const displayed = value ?? internal;
// Error shake — matches Input's behavior (fires on new errors only).
const shakeX = useSharedValue(0);
const hadError = useRef(hasError);
useEffect(() => {
if (hasError && !hadError.current && !reduceMotion) {
shakeX.value = withSequence(
withTiming(-6, SHAKE_SEG),
withTiming(6, SHAKE_SEG),
withTiming(-4, SHAKE_SEG),
withTiming(4, SHAKE_SEG),
withTiming(-2, SHAKE_SEG),
withTiming(0, SHAKE_SEG)
);
}
hadError.current = hasError;
}, [hasError, reduceMotion, shakeX]);
const shakeStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shakeX.value }],
}));
/** Error/helper text entrance: 0 hidden → 1 visible. */
const msgT = useSharedValue(error || helperText ? 1 : 0);
useEffect(() => {
const target = error || helperText ? 1 : 0;
msgT.value = reduceMotion ? target : withTiming(target, MSG_ENTER);
}, [error, helperText, msgT, reduceMotion]);
const msgStyle = useAnimatedStyle(() => ({
opacity: msgT.value,
transform: [{ translateY: (1 - msgT.value) * -4 }],
}));
/** Success check draw progress + pop — the mark lands with a beat. */
const checkDraw = useSharedValue(showSuccess ? 1 : 0);
const checkPop = useSharedValue(showSuccess ? 1 : 0);
const hadSuccess = useRef(showSuccess);
useEffect(() => {
checkDraw.value = reduceMotion ? (showSuccess ? 1 : 0) : withTiming(showSuccess ? 1 : 0, CHECK_DRAW);
if (showSuccess && !hadSuccess.current) {
haptics.success();
if (!reduceMotion) {
// Pop: a shallow dip on timing.recede (120ms — long enough to read,
// an 80ms instant dip flickers past) then spring.pop settles it.
checkPop.value = withSequence(withTiming(0.75, timing.recede), withSpring(1, spring.pop));
} else {
checkPop.value = 1;
}
} else if (!showSuccess) {
checkPop.value = 0;
}
hadSuccess.current = showSuccess;
}, [showSuccess, checkDraw, checkPop, reduceMotion, haptics]);
const checkPopStyle = useAnimatedStyle(() => ({
transform: [{ scale: checkPop.value }],
opacity: checkPop.value,
}));
/** Focus ring — a soft halo that fades+settles around the field while
* editing, on top of the instant border-color swap (both stay since
* the border alone reads thin at 1.5px). */
const ring = useSharedValue(0);
useEffect(() => {
// Suppressed under error/success so the halo never argues with the
// semantic border color.
ring.value = withTiming(focused && !hasError && !showSuccess ? 1 : 0, timing.fast);
}, [focused, hasError, showSuccess, ring]);
const ringStyle = useAnimatedStyle(() => ({
opacity: ring.value * 0.35,
transform: [{ scale: reduceMotion ? 1 : 0.97 + ring.value * 0.03 }],
}));
const checkProps = useAnimatedProps(() => ({
strokeDashoffset: (1 - checkDraw.value) * CHECK_LEN,
}));
// The number of digit slots in the active mask — used to "snap" a light
// haptic the moment the mask fills up (completion feels physical).
const activeMask = mask ?? (preset === "currency" ? undefined : PRESETS[preset]);
const slotCount = activeMask
? (activeMask.match(/#/g) ?? []).length
: Infinity;
// Cap native entry at the mask length (literals included) so the field
// can't overflow its format; currency grows unbounded. Overridable.
const maxLength = maxLengthProp ?? activeMask?.length;
const prevRaw = useRef(0);
const handleChange = (text: string) => {
const raw = digitsOnly(text);
let formatted: string;
if (mask) {
formatted = applyMask(mask, raw);
} else if (preset === "currency") {
formatted = formatCurrency(raw, currencySymbol);
} else {
formatted = applyMask(PRESETS[preset], raw);
}
if (raw.length === slotCount && prevRaw.current === slotCount - 1) {
haptics.light();
}
prevRaw.current = raw.length;
if (value === undefined) setInternal(formatted);
onChangeText?.(raw, formatted);
};
const message = error ?? helperText;
const showLine = !!message || reserveHelperLine;
return (
<View className={cn("w-full gap-1.5", className)}>
{label ? (
<Text className="text-sm font-medium leading-tight text-foreground" accessibilityRole="text">{label}</Text>
) : null}
<Animated.View style={shakeStyle} className="justify-center">
{/* Focus halo — sits outside the border so it glows without
shifting layout. Opacity-only under reduced motion. */}
<Animated.View
pointerEvents="none"
style={ringStyle}
className="absolute -inset-0.5 rounded-[14px] border-2 border-ring"
/>
<TextInput
ref={ref}
value={displayed}
onChangeText={handleChange}
keyboardType="number-pad"
editable={editable}
maxLength={maxLength}
onFocus={(e) => {
setFocused(true);
haptics.selection();
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
className={cn(
// 48px = 44pt+ touch target; px-3.5 aligns text to the 4pt grid.
// border-[1.5px] on state change prevents the 1px→2px layout flicker.
"h-12 w-full rounded-xl border bg-background px-3.5 text-base text-foreground placeholder:text-muted-foreground",
showSuccess && "pr-10",
hasError
? "border-[1.5px] border-destructive"
: showSuccess
? "border-[1.5px] border-success"
: focused
? "border-[1.5px] border-ring"
: "border-input",
!editable && "opacity-50",
inputClassName
)}
accessibilityLabel={label ?? props.accessibilityLabel}
accessibilityHint={describedBy}
accessibilityState={{ disabled: !editable }}
// accessibilityState isn't forwarded to the DOM on web — mirror
// the error with an explicit aria-invalid like Input does.
aria-invalid={!!error}
{...props}
/>
{showSuccess ? (
<View className="absolute right-3 top-0 h-full items-center justify-center" pointerEvents="none">
<Animated.View style={checkPopStyle}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<AnimatedPath
d={SUCCESS_PATH}
stroke={colors.success}
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={CHECK_LEN}
animatedProps={checkProps}
/>
</Svg>
</Animated.View>
</View>
) : null}
</Animated.View>
{showLine ? (
<Animated.View style={[{ minHeight: 20 }, msgStyle]}>
{error ? (
<Text
className="text-sm text-destructive"
accessibilityLiveRegion="polite"
accessibilityRole="alert"
>
{error}
</Text>
) : helperText ? (
<Text className="text-sm text-muted-foreground">{helperText}</Text>
) : null}
</Animated.View>
) : null}
</View>
);
}
);
MaskedInput.displayName = "MaskedInput";
| value? | string | Formatted value (controlled). Omit with `defaultValue` for uncontrolled. | |
| defaultValue? | string | Initial formatted value (uncontrolled). | |
| onChangeText? | (raw: string, formatted: string) => void | Called with (raw, formatted). | |
| mask? | string | Custom mask — `#` marks a digit slot. Overrides `preset`. | |
| preset? | MaskPreset | Built-in mask preset. Default "phone" when neither mask nor preset given. | |
| currencySymbol? | string | Currency symbol prefix. Default "$". | |
| label? | string | ||
| helperText? | string | ||
| error? | string | Error message — switches input to destructive styling. | |
| success? | boolean | Shows an animated success check on the right edge (suppressed while `error` is set). | |
| reserveHelperLine? | boolean | Reserve one helper line even when empty — prevents layout shift on error. | |
| className? | string | ||
| inputClassName? | string |
Text input with format masking — phone, card, currency, and custom '#' digit masks — returning raw and formatted values with label/helper/error/success states
import { MaskedInput } from "@/components/ui/masked-input/masked-input";
export function Example() {
return (
<MaskedInput />
);
} import { forwardRef, useEffect, useRef, useState } from "react";
import { Text, TextInput, View, type TextInputProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedProps,
withSpring,
withTiming,
withSequence,
} from "react-native-reanimated";
import Svg from "react-native-svg";
import { cn, spring, timing, useHaptic, useReducedMotion, useTheme, AnimatedPath } from "@lumioui/core";
/**
* MaskedInput — PRO component. A TextInput that applies a format mask as the
* user types. Built-in presets for phone "(###) ###-####", card
* "#### #### #### ####", and currency; or pass a custom `mask` where
* `#` is a digit placeholder. `onChangeText` receives the raw digits plus
* the formatted string. Expo Go compatible.
*/
export type MaskPreset = "phone" | "card" | "currency";
const SUCCESS_PATH = "M5 13l4 4L19 7";
/** Upper bound of the check path length — used for the dasharray trick. */
const CHECK_LEN = 24;
/** Error/helper text entrance — slightly slower than press feedback so the message reads as arriving, not flashing. */
const MSG_ENTER = { duration: 180 };
/** Success check draw-on — long enough to register as a deliberate mark, not a flicker. */
const CHECK_DRAW = { duration: 220 };
/** BeUI-style error shake segment — matches Input's choreography (0 → -6 → 6 → -4 → 4 → -2 → 0 over ~450ms). */
const SHAKE_SEG = { duration: 75 };
const PRESETS: Record<Exclude<MaskPreset, "currency">, string> = {
phone: "(###) ###-####",
card: "#### #### #### ####",
};
export interface MaskedInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "className"> {
/** Formatted value (controlled). Omit with `defaultValue` for uncontrolled. */
value?: string;
/** Initial formatted value (uncontrolled). */
defaultValue?: string;
/** Called with (raw, formatted). */
onChangeText?: (raw: string, formatted: string) => void;
/** Custom mask — `#` marks a digit slot. Overrides `preset`. */
mask?: string;
/** Built-in mask preset. Default "phone" when neither mask nor preset given. */
preset?: MaskPreset;
/** Currency symbol prefix. Default "$". */
currencySymbol?: string;
label?: string;
helperText?: string;
/** Error message — switches input to destructive styling. */
error?: string;
/** Shows an animated success check on the right edge (suppressed while `error` is set). */
success?: boolean;
/** Reserve one helper line even when empty — prevents layout shift on error. */
reserveHelperLine?: boolean;
className?: string;
inputClassName?: string;
}
function digitsOnly(text: string): string {
return text.replace(/\D/g, "");
}
/** Apply a `#`-digit mask to raw digits. */
export function applyMask(mask: string, raw: string): string {
let out = "";
let i = 0;
for (const ch of mask) {
if (i >= raw.length) break;
if (ch === "#") {
out += raw[i];
i += 1;
} else {
out += ch;
}
}
return out;
}
function formatCurrency(raw: string, symbol: string): string {
if (!raw) return "";
const cents = parseInt(raw, 10);
const value = cents / 100;
return `${symbol}${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
export const MaskedInput = forwardRef<TextInput, MaskedInputProps>(
function MaskedInput(
{
value,
defaultValue,
onChangeText,
mask,
preset = "phone",
currencySymbol = "$",
label,
helperText,
error,
success = false,
reserveHelperLine = false,
className,
inputClassName,
editable = true,
maxLength: maxLengthProp,
onFocus,
onBlur,
...props
},
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const [focused, setFocused] = useState(false);
const hasError = Boolean(error);
const showSuccess = success && !error;
const reduceMotion = useReducedMotion();
const describedBy = error ?? helperText;
// Uncontrolled state holds the formatted string so the mask applies
// even when no `value` is passed; controlled mode wins when it is.
const [internal, setInternal] = useState(value ?? defaultValue ?? "");
const displayed = value ?? internal;
// Error shake — matches Input's behavior (fires on new errors only).
const shakeX = useSharedValue(0);
const hadError = useRef(hasError);
useEffect(() => {
if (hasError && !hadError.current && !reduceMotion) {
shakeX.value = withSequence(
withTiming(-6, SHAKE_SEG),
withTiming(6, SHAKE_SEG),
withTiming(-4, SHAKE_SEG),
withTiming(4, SHAKE_SEG),
withTiming(-2, SHAKE_SEG),
withTiming(0, SHAKE_SEG)
);
}
hadError.current = hasError;
}, [hasError, reduceMotion, shakeX]);
const shakeStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shakeX.value }],
}));
/** Error/helper text entrance: 0 hidden → 1 visible. */
const msgT = useSharedValue(error || helperText ? 1 : 0);
useEffect(() => {
const target = error || helperText ? 1 : 0;
msgT.value = reduceMotion ? target : withTiming(target, MSG_ENTER);
}, [error, helperText, msgT, reduceMotion]);
const msgStyle = useAnimatedStyle(() => ({
opacity: msgT.value,
transform: [{ translateY: (1 - msgT.value) * -4 }],
}));
/** Success check draw progress + pop — the mark lands with a beat. */
const checkDraw = useSharedValue(showSuccess ? 1 : 0);
const checkPop = useSharedValue(showSuccess ? 1 : 0);
const hadSuccess = useRef(showSuccess);
useEffect(() => {
checkDraw.value = reduceMotion ? (showSuccess ? 1 : 0) : withTiming(showSuccess ? 1 : 0, CHECK_DRAW);
if (showSuccess && !hadSuccess.current) {
haptics.success();
if (!reduceMotion) {
// Pop: a shallow dip on timing.recede (120ms — long enough to read,
// an 80ms instant dip flickers past) then spring.pop settles it.
checkPop.value = withSequence(withTiming(0.75, timing.recede), withSpring(1, spring.pop));
} else {
checkPop.value = 1;
}
} else if (!showSuccess) {
checkPop.value = 0;
}
hadSuccess.current = showSuccess;
}, [showSuccess, checkDraw, checkPop, reduceMotion, haptics]);
const checkPopStyle = useAnimatedStyle(() => ({
transform: [{ scale: checkPop.value }],
opacity: checkPop.value,
}));
/** Focus ring — a soft halo that fades+settles around the field while
* editing, on top of the instant border-color swap (both stay since
* the border alone reads thin at 1.5px). */
const ring = useSharedValue(0);
useEffect(() => {
// Suppressed under error/success so the halo never argues with the
// semantic border color.
ring.value = withTiming(focused && !hasError && !showSuccess ? 1 : 0, timing.fast);
}, [focused, hasError, showSuccess, ring]);
const ringStyle = useAnimatedStyle(() => ({
opacity: ring.value * 0.35,
transform: [{ scale: reduceMotion ? 1 : 0.97 + ring.value * 0.03 }],
}));
const checkProps = useAnimatedProps(() => ({
strokeDashoffset: (1 - checkDraw.value) * CHECK_LEN,
}));
// The number of digit slots in the active mask — used to "snap" a light
// haptic the moment the mask fills up (completion feels physical).
const activeMask = mask ?? (preset === "currency" ? undefined : PRESETS[preset]);
const slotCount = activeMask
? (activeMask.match(/#/g) ?? []).length
: Infinity;
// Cap native entry at the mask length (literals included) so the field
// can't overflow its format; currency grows unbounded. Overridable.
const maxLength = maxLengthProp ?? activeMask?.length;
const prevRaw = useRef(0);
const handleChange = (text: string) => {
const raw = digitsOnly(text);
let formatted: string;
if (mask) {
formatted = applyMask(mask, raw);
} else if (preset === "currency") {
formatted = formatCurrency(raw, currencySymbol);
} else {
formatted = applyMask(PRESETS[preset], raw);
}
if (raw.length === slotCount && prevRaw.current === slotCount - 1) {
haptics.light();
}
prevRaw.current = raw.length;
if (value === undefined) setInternal(formatted);
onChangeText?.(raw, formatted);
};
const message = error ?? helperText;
const showLine = !!message || reserveHelperLine;
return (
<View className={cn("w-full gap-1.5", className)}>
{label ? (
<Text className="text-sm font-medium leading-tight text-foreground" accessibilityRole="text">{label}</Text>
) : null}
<Animated.View style={shakeStyle} className="justify-center">
{/* Focus halo — sits outside the border so it glows without
shifting layout. Opacity-only under reduced motion. */}
<Animated.View
pointerEvents="none"
style={ringStyle}
className="absolute -inset-0.5 rounded-[14px] border-2 border-ring"
/>
<TextInput
ref={ref}
value={displayed}
onChangeText={handleChange}
keyboardType="number-pad"
editable={editable}
maxLength={maxLength}
onFocus={(e) => {
setFocused(true);
haptics.selection();
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
className={cn(
// 48px = 44pt+ touch target; px-3.5 aligns text to the 4pt grid.
// border-[1.5px] on state change prevents the 1px→2px layout flicker.
"h-12 w-full rounded-xl border bg-background px-3.5 text-base text-foreground placeholder:text-muted-foreground",
showSuccess && "pr-10",
hasError
? "border-[1.5px] border-destructive"
: showSuccess
? "border-[1.5px] border-success"
: focused
? "border-[1.5px] border-ring"
: "border-input",
!editable && "opacity-50",
inputClassName
)}
accessibilityLabel={label ?? props.accessibilityLabel}
accessibilityHint={describedBy}
accessibilityState={{ disabled: !editable }}
// accessibilityState isn't forwarded to the DOM on web — mirror
// the error with an explicit aria-invalid like Input does.
aria-invalid={!!error}
{...props}
/>
{showSuccess ? (
<View className="absolute right-3 top-0 h-full items-center justify-center" pointerEvents="none">
<Animated.View style={checkPopStyle}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<AnimatedPath
d={SUCCESS_PATH}
stroke={colors.success}
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={CHECK_LEN}
animatedProps={checkProps}
/>
</Svg>
</Animated.View>
</View>
) : null}
</Animated.View>
{showLine ? (
<Animated.View style={[{ minHeight: 20 }, msgStyle]}>
{error ? (
<Text
className="text-sm text-destructive"
accessibilityLiveRegion="polite"
accessibilityRole="alert"
>
{error}
</Text>
) : helperText ? (
<Text className="text-sm text-muted-foreground">{helperText}</Text>
) : null}
</Animated.View>
) : null}
</View>
);
}
);
MaskedInput.displayName = "MaskedInput";