Skip to the content

ComponentsForm

Input mask

A text field that keeps a pattern, such as a phone number or a date, and fills its slots as people type.

Import

import { InputMask } from "@booleanpress/ui/input-mask"

Usage

InputMask is the library's Input with a mask. mask is the pattern: 9 takes a digit, a a letter (A–Z), * a letter or a digit, and every other character is fixed and typed for you. Name it with a Label whose htmlFor is its id.

import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export function SupportPhone() {
  return (
    <>
      <Label htmlFor="support-phone">Support phone</Label>
      <InputMask id="support-phone" type="tel" mask="(999) 999-9999" placeholder="(999) 999-9999" />
    </>
  )
}

Typing fills the next slot and skips the fixed characters; a character that does not fit its slot is refused. Backspace and Delete remove a character and move the rest back. Pasted text is read as if typed, so 555-123-4567 fills (555) 123-4567. Empty slots show slotChar (_), or a string as long as the pattern, such as mm/dd/yyyy. Everything after ? is optional. When the field loses focus with the required part unfinished, autoClear (on by default) empties it; autoClear={false} keeps what was typed.

It is uncontrolled with defaultValue, or controlled with value and onValueChange, which is called with the new value and { complete }. The value is what the field shows, (555) 123-4567; with unmask it is the typed characters only, 5551234567, in value, defaultValue and onValueChange alike. onChange still receives the input's event, with the text it shows. A field whose slots are all digits opens the number pad on phones. With name, the form submits the text the field shows, mask included, even with unmask; a reset of the form puts an uncontrolled field back to its defaultValue. It takes the other props of Input: size, variant, clearable, aria-invalid.

Examples

Basic

A phone number: the brackets, the space and the dash are typed for you.

import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export default function InputMaskBasic() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="support-phone">Support phone</Label>
      <InputMask id="support-phone" type="tel" mask="(999) 999-9999" placeholder="(999) 999-9999" />
    </div>
  )
}

Patterns

A date, a licence key mixing letters and digits, and a tax number.

import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export default function InputMaskPatterns() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="invoice-date">Invoice date</Label>
        <InputMask id="invoice-date" mask="99/99/9999" placeholder="99/99/9999" />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="licence-key">Licence key</Label>
        <InputMask id="licence-key" mask="a*-999-a999" placeholder="a*-999-a999" />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="tax-number">Tax number</Label>
        <InputMask id="tax-number" mask="999-99-9999" placeholder="999-99-9999" />
      </div>
    </div>
  )
}

Optional part

Everything after ? is optional: the number is complete without the extension.

import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export default function InputMaskOptional() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="office-phone">Office phone, extension optional</Label>
      <InputMask id="office-phone" type="tel" mask="(999) 999-9999? x99999" placeholder="(999) 999-9999? x99999" />
    </div>
  )
}

Slot character

slotChar="mm/dd/yyyy" shows what each empty slot is for.

import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export default function InputMaskSlotCharacter() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="renewal-date">Renewal date</Label>
      <InputMask id="renewal-date" mask="99/99/9999" slotChar="mm/dd/yyyy" placeholder="mm/dd/yyyy" />
    </div>
  )
}

Unmasked value

With unmask, onValueChange reports the digits only, while the field shows the mask.

import { useState } from "react"
import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export default function InputMaskUnmask() {
  const [raw, setRaw] = useState("")
  const [masked, setMasked] = useState("")

  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="sms-number">SMS alerts number</Label>
      <InputMask
        id="sms-number"
        type="tel"
        mask="(999) 999-9999"
        placeholder="(999) 999-9999"
        unmask
        onValueChange={setRaw}
        onChange={(event) => setMasked(event.target.value)}
      />
      <dl className="grid grid-cols-[auto_1fr] gap-x-3 text-sm text-muted-foreground">
        <dt>Value</dt>
        <dd className="font-mono text-foreground">{raw || "—"}</dd>
        <dt>Shown</dt>
        <dd className="font-mono text-foreground">{masked || "—"}</dd>
      </dl>
    </div>
  )
}

Auto-clear

By default an unfinished number is cleared when the field loses focus; autoClear={false} keeps it.

import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export default function InputMaskAutoClear() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="billing-phone">Billing phone, cleared when unfinished</Label>
        <InputMask id="billing-phone" type="tel" mask="(999) 999-9999" placeholder="(999) 999-9999" />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="backup-phone">Backup phone, kept when unfinished</Label>
        <InputMask
          id="backup-phone"
          type="tel"
          mask="(999) 999-9999"
          placeholder="(999) 999-9999"
          autoClear={false}
          defaultValue="555"
        />
      </div>
    </div>
  )
}

Sizes

sm is 28 px tall with 12 px text, default 35 px with 14 px, lg 42 px with 16 px.

import { InputMask } from "@booleanpress/ui/input-mask"

export default function InputMaskSizes() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <InputMask size="sm" mask="99/99/9999" aria-label="Small" placeholder="Small" />
      <InputMask mask="99/99/9999" aria-label="Normal" placeholder="Normal" />
      <InputMask size="lg" mask="99/99/9999" aria-label="Large" placeholder="Large" />
    </div>
  )
}

Disabled

The masked value shows, and cannot be changed or submitted.

import { InputMask } from "@booleanpress/ui/input-mask"

export default function InputMaskDisabled() {
  return (
    <div className="w-full max-w-xs">
      <InputMask disabled mask="(999) 999-9999" aria-label="Support phone" defaultValue="5551234567" />
    </div>
  )
}

Invalid

aria-invalid draws the error edge; aria-describedby reads the message with the field.

import { InputMask } from "@booleanpress/ui/input-mask"
import { Label } from "@booleanpress/ui/label"

export default function InputMaskInvalid() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="invalid-phone">Support phone</Label>
      <InputMask
        id="invalid-phone"
        type="tel"
        mask="(999) 999-9999"
        placeholder="(999) 999-9999"
        aria-invalid
        aria-describedby="invalid-phone-error"
      />
      <p id="invalid-phone-error" className="text-sm text-destructive-strong">
        Enter all ten digits of the number.
      </p>
    </div>
  )
}

Accessibility

Semantics
A native input, with inputmode="numeric" when every slot takes a digit.
Labels
Name it with a Label or aria-label. A placeholder showing the pattern is not a name; say the format in the label or in a hint read through aria-describedby.
Focus
One tab stop. Focus turns the edge --ring. Focusing an unfinished value puts the caret at its first empty slot.
Known limits
  • Undo and redo (⌘Z, ⇧⌘Z, the Edit menu) do nothing in a masked field: the mask rewrites the text on every keystroke, so the browser's history would put back half-masked text.
  • A refused character is not announced: say the format in the label or a hint.
  • The fixed characters and empty slots are part of the text, so a screen reader reads them as it reads any text.
  • Text composed with an input method editor is read once it is committed.
  • A slot letter is A–Z; a refuses accented letters.

Keyboard

Keyboard
KeyBehaviour
0–9orA–ZFills the next slot when the character fits it, skipping the fixed characters; otherwise it is refused.
BackspaceRemoves the character before the caret; the characters after it move back.
DeleteRemoves the character after the caret; the characters after it move back.
CtrlVPastes text as if typed: the fixed characters and anything that fits no slot are dropped.

API

InputMask

InputMask props
PropTypeDefaultDescription
maskrequiredstringThe pattern: 9 a digit, a a letter, * a letter or a digit, ? starts the optional part; anything else is fixed.
autoClearbooleantrueEmpties the field when it loses focus with the required part unfinished.
clearablebooleanShows a button that empties the field while it has a value.
defaultValuestringThe starting value of an uncontrolled field, in the same form as value.
onValueChange((value: string, details: { complete: boolean; }) => void)Called with the new value on every change, and whether the required part is filled.
size"default" | "sm" | "lg"The field's size: 28, 35 or 42 px tall. Defaults to the provider's controlSize.
slotCharstring_What an empty slot shows: one character for every slot, or a string as long as the pattern (mm/dd/yyyy).
unmaskbooleanfalsevalue, defaultValue and onValueChange carry the typed characters only (5551234567), not the mask.
valuestringThe value, as the field shows it, or the typed characters only with unmask.
variant"default" | "filled"filled draws the grey --field-filled fill. Defaults to the provider's fieldVariant.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="input-mask" (InputMask), and data-mask.

Theming

The field is Input's: --field, --control, --ring, --invalid and --field-disabled.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for