A mask hook for React 19, no package needed
lib/use-mask.ts
"use client";
import { useLayoutEffect, useRef, useState, type ChangeEvent } from "react";
// In the pattern, 9 is one digit. Any other character is typed for you
// once the digits reach it: "(999) 999-9999", "99/99/9999", "9999 9999 9999 9999".
export function formatMask(raw: string, mask: string) {
const digits = raw.replace(/\D/g, "");
let out = "";
let d = 0;
for (const ch of mask) {
if (d >= digits.length) break;
out += ch === "9" ? digits[d++] : ch;
}
return out;
}
export function useMask(mask: string, initial = "") {
const ref = useRef<HTMLInputElement>(null);
// caret travels with the value, so a keystroke that changes nothing still re-renders
const [state, setState] = useState(() => ({ value: formatMask(initial, mask), caret: -1 }));
useLayoutEffect(() => {
const el = ref.current;
if (el && state.caret >= 0 && document.activeElement === el) el.setSelectionRange(state.caret, state.caret);
}, [state]);
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
const pos = e.target.selectionStart ?? raw.length;
const digitsBefore = raw.slice(0, pos).replace(/\D/g, "").length;
const value = formatMask(raw, mask);
// put the caret right after the same digit it followed before formatting
let caret = 0;
for (let seen = 0; caret < value.length && seen < digitsBefore; caret++) {
if (/\d/.test(value[caret])) seen++;
}
setState({ value, caret });
};
return {
inputProps: { ref, value: state.value, onChange },
digits: state.value.replace(/\D/g, ""),
complete: state.value.length === mask.length,
};
}
app/signup/contact-fields.tsx
"use client";
// Before:
// import InputMask from "react-input-mask";
// <InputMask mask="(999) 999-9999" value={phone} onChange={(e) => setPhone(e.target.value)} />
import { useMask } from "@/lib/use-mask";
export default function ContactFields() {
const phone = useMask("(999) 999-9999");
const birthday = useMask("99/99/9999");
return (
<div className="grid gap-3">
<label className="grid gap-1 text-sm">
Phone
<input {...phone.inputProps} name="phone" inputMode="tel" autoComplete="tel" placeholder="(555) 123-4567" className="rounded-md border px-3 py-2" />
</label>
<label className="grid gap-1 text-sm">
Birthday
<input {...birthday.inputProps} name="birthday" inputMode="numeric" placeholder="MM/DD/YYYY" className="rounded-md border px-3 py-2" />
</label>
{/* phone.digits is "5551234567": send that, not the formatted text */}
<input type="hidden" name="phoneDigits" value={phone.digits} />
</div>
);
}