v1.0

InputMask

Preview

Basic

Loading…

Preview

Code

ts
import InputMask from '@/components/form/InputMask';

src/components/form/InputMask.tsx

AI prompt

text
Build a masked text input component in React + TypeScript + Tailwind CSS for phone numbers, dates and codes.

## Look
- A standard text input plus `font-mono tabular-nums`. Placeholder defaults to the mask with every slot shown as the slot char, e.g. "(___) ___-____".
- While focused (or once anything is typed) the input shows the full template with empty slots as `slotChar` (default `_`); an empty unfocused field shows its placeholder.

## Behaviour
- Mask tokens: `9` digit, `a` letter, `*` letter or digit; every other character is a literal typed for the user.
- Model: `raw` = the contiguous run of slot characters. Expose a pure `applyMask(mask, input, slotChar)` that accepts raw ("5551234567") OR formatted ("(555) 123-4567") input — a character equal to the literal at the current position is consumed as that literal — and returns `{ formatted, masked, raw, complete }`: `formatted` trimmed after the last filled slot ("(555) 12"), `masked` with empty slots as the slot char, `complete` when every slot is filled. Characters that don't fit a slot are dropped.
- Work out edits by DIFFING the browser's new value against the old (common prefix capped at the old caret, common suffix), not by intercepting keys — one path covers typing, paste, autofill, drag-drop and mobile keyboards. A pasted fully-formatted value has the mask's leading literals (e.g. "+1 (") stripped first.
- Intercept only Backspace and Delete: they skip literals and remove the nearest slot character (or the selected range). A change event that only deleted a literal (Android backspace) deletes the slot before it.
- Caret: after every edit place it at the slot where the next character lands, in a layout effect (force a re-render even when a keystroke was rejected, so the caret is restored). On focus, move the caret to the first empty slot on the next frame — unless a keystroke already got in. A mouse-up past the typed part snaps back to the first empty slot.
- `autoClear`: on blur, an incomplete value is cleared.
- `inputMode="numeric"` automatically when every slot is `9`. Default `type="text"` (overridable with `tel`; never email/number, which lack setSelectionRange).

## API
`mask: string`, `value: string` (raw or formatted — both are read through applyMask), `onChange({ formatted, raw, complete })`, `slotChar = '_'` (must not be accepted by any slot), `autoClear = false`, plus native input props (forwarded ref; the component's own handlers call the caller's onFocus/onBlur/onKeyDown/onSelect/onMouseUp).

## Demo
Phone "(999) 999-9999" with `type="tel"` and the change object printed as JSON; date "99/99/9999" with autoClear starting from raw "1225"; code "aa-9999" with slot char "·".

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { forwardRef, useLayoutEffect, useReducer, useRef, useState } from 'react';
import { cn } from '@/lib/cn';

/** Mask tokens. Every other character in a mask is a literal, typed for the user. */
const TOKENS: Record<string, RegExp> = {
  '9': /\d/,
  a: /[A-Za-z]/,
  '*': /[A-Za-z0-9]/,
};

const isSlot = (m: string) => m in TOKENS;

export type MaskResult = {
  /** The value as shown, trimmed after the last filled slot: "(555) 12". Empty when nothing is filled. */
  formatted: string;
  /** Every slot rendered, empty ones as `slotChar`: "(555) 12_-____". */
  masked: string;
  /** Only the characters typed into slots: "55512". */
  raw: string;
  /** Every slot filled. */
  complete: boolean;
};

export type MaskChange = Pick<MaskResult, 'formatted' | 'raw' | 'complete'>;

/** Strict fill: `raw` holds slot characters only. One that does not fit its slot is dropped and the rest move up. */
function fill(mask: string, raw: string, slotChar: string): MaskResult {
  let masked = '';
  let accepted = '';
  let lastFilled = -1;
  let ri = 0;
  for (let pos = 0; pos < mask.length; pos++) {
    const m = mask[pos];
    if (!isSlot(m)) {
      masked += m;
      continue;
    }
    while (ri < raw.length && !TOKENS[m].test(raw[ri])) ri++;
    if (ri < raw.length) {
      masked += raw[ri];
      accepted += raw[ri];
      lastFilled = pos;
      ri++;
    } else {
      masked += slotChar;
    }
  }
  const slots = [...mask].filter(isSlot).length;
  return {
    masked,
    raw: accepted,
    formatted: lastFilled < 0 ? '' : masked.slice(0, lastFilled + 1),
    complete: accepted.length === slots,
  };
}

/**
 * Walk `input` against the mask from position `start`, keeping the characters
 * that land in slots. A character equal to the literal at the current position
 * is consumed AS that literal, which is what lets an already-formatted string
 * ("(555) 123-4567") come back out as its raw digits; anything else that does
 * not fit is dropped.
 */
function extract(mask: string, input: string, start = 0): string {
  let raw = '';
  let pos = start;
  for (const ch of input) {
    // Skip literals the input did not spell out, e.g. raw digits against "(999)".
    while (pos < mask.length && !isSlot(mask[pos]) && mask[pos] !== ch) pos++;
    if (pos >= mask.length) break;
    if (!isSlot(mask[pos])) {
      pos++;
      continue;
    }
    if (TOKENS[mask[pos]].test(ch)) {
      raw += ch;
      pos++;
    }
  }
  return raw;
}

/**
 * Format `input` — raw ("5551234567") or already formatted ("(555) 123-4567")
 * — against `mask`. Pure, so the same function can validate on the server.
 *
 * `9` is a digit, `a` a letter, `*` either; every other mask character is a
 * literal. A raw input may not contain a mask literal as data (an `x` typed
 * into an `a` slot of "99x-aa" reads as the literal) — keep masks' literals
 * out of their own slots' alphabets.
 */
export function applyMask(mask: string, input: string, slotChar = '_'): MaskResult {
  return fill(mask, extract(mask, input), slotChar);
}

export interface InputMaskProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange'> {
  /** `9` digit, `a` letter, `*` letter or digit, anything else literal — "(999) 999-9999". */
  mask: string;
  /** Raw or formatted; both are read back through `applyMask`. */
  value: string;
  /** Receives `formatted`, `raw` and `complete` together, so the caller stores whichever it wants. */
  onChange: (next: MaskChange) => void;
  /** Shown in empty slots while focused. Must not be a character any slot accepts. */
  slotChar?: string;
  /** Clear the value on blur when it is incomplete, rather than keeping a half-typed date. */
  autoClear?: boolean;
}

/**
 * A text input that types into a fixed pattern.
 *
 * The model is a CONTIGUOUS run of slot characters (`raw`), rendered through
 * the mask. Edits are worked out by diffing the browser's new value against
 * the old one, rather than by intercepting keys, because that one path covers
 * typing, paste, autofill, drag-drop and mobile keyboards that never send a
 * usable `keydown`. Only Backspace and Delete are intercepted: left to the
 * browser, backspacing over ") " deletes a literal that immediately
 * re-renders, so the caret appears stuck. Handling them here makes both keys
 * skip literals to the nearest slot.
 *
 * The caret is placed after every edit in a layout effect — a controlled
 * input whose value React rewrites otherwise jumps its caret to the end.
 */
const InputMask = forwardRef<HTMLInputElement, InputMaskProps>(function InputMask(
  {
    mask,
    value,
    onChange,
    slotChar = '_',
    autoClear = false,
    placeholder,
    inputMode,
    className,
    onFocus,
    onBlur,
    onKeyDown,
    onSelect,
    onMouseUp,
    readOnly,
    disabled,
    ...rest
  },
  ref,
) {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const selection = useRef({ start: 0, end: 0 });
  const pendingCaret = useRef<number | null>(null);
  /** Set by any edit, so the deferred focus placement cannot yank the caret back mid-typing. */
  const editedSinceFocus = useRef(false);
  const [focused, setFocused] = useState(false);
  // Forces a render even when the parent's value does not change (a rejected
  // keystroke), so the layout effect runs and restores the caret.
  const [, rerender] = useReducer((n: number) => n + 1, 0);

  const slots: number[] = [];
  for (let i = 0; i < mask.length; i++) if (isSlot(mask[i])) slots.push(i);

  const current = applyMask(mask, value ?? '', slotChar);
  const raw = current.raw;
  const display = focused || raw ? current.masked : '';
  const template = mask.replace(/[9a*]/g, slotChar);

  /** Slots strictly before `pos` — i.e. the raw index an edit at `pos` affects. */
  const slotIndexAt = (pos: number) => slots.filter((p) => p < pos).length;
  /** Where the caret sits for "before raw index k": on that slot, or past the last one. */
  const caretFor = (k: number) => (k < slots.length ? slots[k] : mask.length);

  useLayoutEffect(() => {
    const el = inputRef.current;
    const pos = pendingCaret.current;
    pendingCaret.current = null;
    if (el && pos !== null && document.activeElement === el) {
      el.setSelectionRange(pos, pos);
      selection.current = { start: pos, end: pos };
    }
  });

  const emit = (nextRaw: string, caretIndex: number) => {
    editedSinceFocus.current = true;
    const res = fill(mask, nextRaw, slotChar);
    pendingCaret.current = caretFor(Math.min(caretIndex, res.raw.length));
    if (res.raw !== raw) onChange({ formatted: res.formatted, raw: res.raw, complete: res.complete });
    rerender();
  };

  const remember = (el: HTMLInputElement) => {
    selection.current = { start: el.selectionStart ?? 0, end: el.selectionEnd ?? 0 };
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;
    const old = display;
    const { start } = selection.current;

    // Common prefix, capped at the old caret so a repeated character typed
    // before an identical one ("5" before "55") is placed where it was typed.
    let p = 0;
    while (p < old.length && p < next.length && old[p] === next[p]) p++;
    p = Math.min(p, start);
    let q = 0;
    while (q < Math.min(old.length, next.length) - p && old[old.length - 1 - q] === next[next.length - 1 - q]) q++;
    const removedEnd = old.length - q;
    let inserted = next.slice(p, next.length - q);

    const rawStart = Math.min(slotIndexAt(p), raw.length);
    const rawEnd = Math.min(slotIndexAt(removedEnd), raw.length);

    if (!inserted && rawStart === rawEnd) {
      // Only a literal was deleted — the Backspace that reached us without a
      // keydown (Android). Treat it as deleting the slot before it.
      if (rawStart > 0) emit(raw.slice(0, rawStart - 1) + raw.slice(rawStart), rawStart - 1);
      else emit(raw, 0);
      return;
    }

    // A paste of a full formatted value lands at slot 0, i.e. AFTER the mask's
    // leading literals ("+1 ("); strip them from the paste too, or the "1" of
    // "+1" would be read as the first digit.
    const lead = mask.slice(0, slots[0] ?? 0);
    if (rawStart === 0 && lead && inserted.startsWith(lead)) inserted = inserted.slice(lead.length);

    const accepted = rawStart < slots.length ? extract(mask, inserted, slots[rawStart]) : '';
    emit(raw.slice(0, rawStart) + accepted + raw.slice(rawEnd), rawStart + accepted.length);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    onKeyDown?.(e);
    if (e.defaultPrevented || readOnly || disabled) return;
    if (e.key !== 'Backspace' && e.key !== 'Delete') {
      remember(e.currentTarget);
      return;
    }
    e.preventDefault();
    const s = e.currentTarget.selectionStart ?? 0;
    const end = e.currentTarget.selectionEnd ?? s;

    if (s !== end) {
      const a = Math.min(slotIndexAt(s), raw.length);
      const b = Math.min(slotIndexAt(end), raw.length);
      emit(raw.slice(0, a) + raw.slice(b), a);
    } else if (e.key === 'Backspace') {
      const k = Math.min(slotIndexAt(s), raw.length) - 1;
      if (k >= 0) emit(raw.slice(0, k) + raw.slice(k + 1), k);
    } else {
      const k = slotIndexAt(s);
      if (k < raw.length) emit(raw.slice(0, k) + raw.slice(k + 1), k);
    }
  };

  const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
    onFocus?.(e);
    setFocused(true);
    editedSinceFocus.current = false;
    // After the browser has placed the caret for the click that focused us:
    // start typing at the first empty slot, not wherever the pointer landed.
    // Skipped if a keystroke got in first — `raw` here is this render's, and
    // moving the caret after fast typing would send the next key to slot 0.
    const el = e.currentTarget;
    const pos = caretFor(raw.length);
    requestAnimationFrame(() => {
      if (document.activeElement !== el || editedSinceFocus.current) return;
      el.setSelectionRange(pos, pos);
      remember(el);
    });
  };

  const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    onBlur?.(e);
    setFocused(false);
    if (autoClear && raw && !current.complete) onChange({ formatted: '', raw: '', complete: false });
  };

  const handleMouseUp = (e: React.MouseEvent<HTMLInputElement>) => {
    onMouseUp?.(e);
    // A click past the typed part would leave the caret in empty slots, where
    // typing still appends to `raw` — move it to where the character will land.
    const el = e.currentTarget;
    const firstEmpty = caretFor(raw.length);
    if (el.selectionStart === el.selectionEnd && (el.selectionStart ?? 0) > firstEmpty) {
      el.setSelectionRange(firstEmpty, firstEmpty);
    }
    remember(el);
  };

  const numericOnly = slots.length > 0 && slots.every((p) => mask[p] === '9');

  return (
    <input
      // Before the spread so `type="tel"` can replace it. Not `email`/`number`:
      // those do not support setSelectionRange, which the caret handling needs.
      type="text"
      {...rest}
      ref={(el) => {
        inputRef.current = el;
        if (typeof ref === 'function') ref(el);
        else if (ref) ref.current = el;
      }}
      value={display}
      placeholder={placeholder ?? template}
      inputMode={inputMode ?? (numericOnly ? 'numeric' : undefined)}
      readOnly={readOnly}
      disabled={disabled}
      onChange={handleChange}
      onKeyDown={handleKeyDown}
      onFocus={handleFocus}
      onBlur={handleBlur}
      onMouseUp={handleMouseUp}
      onSelect={(e) => {
        onSelect?.(e);
        remember(e.currentTarget);
      }}
      className={cn('field-input font-mono tabular-nums', className)}
    />
  );
});

export default InputMask;

Props

PropTypeDefaultDescription
mask*string—`9` digit, `a` letter, `*` letter or digit, anything else literal — "(999) 999-9999".
value*string—Raw or formatted; both are read back through `applyMask`.
onChange*(next: MaskChange) => void—Receives `formatted`, `raw` and `complete` together, so the caller stores whichever it wants.
slotCharstring'_'Shown in empty slots while focused. Must not be a character any slot accepts.
autoClearbooleanfalseClear the value on blur when it is incomplete, rather than keeping a half-typed date.

Also accepts every prop <input> takes — they are spread onto the root element.