Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Masked Input

A text input that formats what is typed to a fixed pattern: a phone number, a card number, an expiry date, a code.

Rendered by React 19.3.1

Loading React…
import { Field } from '@hamur/react/field'
import { MaskedInput } from '@hamur/react/masked-input'

const withoutCountryCode = (text: string) => text.replace(/^\+1\s*/, '')

export default function MaskedInputExample() {
  return (
    <Field.Root name="phone" className="field">
      <Field.Label>Phone number</Field.Label>
      <MaskedInput.Root
        className="masked-input"
        mask="(999) 999-9999"
        normalizeValue={withoutCountryCode}
      >
        <MaskedInput.Input
          className="input masked-input-input"
          autoComplete="tel-national"
        />
        <MaskedInput.Guide className="masked-input-guide" />
      </MaskedInput.Root>
      <Field.Description className="muted">10 digits, area code first.</Field.Description>
    </Field.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<MaskedInput.Root>
  <MaskedInput.Input />
  <MaskedInput.Guide />
</MaskedInput.Root>

Examples

Card

A card number whose mask function groups 15 digits for 34 and 37, checked by Field's validate.

MaskedInputCard.tsx
import { Field } from '@hamur/react/field'
import { MaskedInput } from '@hamur/react/masked-input'

const cardMask = (value: string) =>
  /^3[47]/.test(value) ? '9999 999999 99999' : '9999 9999 9999 9999'

function passesLuhn(digits: string) {
  let sum = 0
  for (let index = 0; index < digits.length; index += 1) {
    let digit = Number(digits[digits.length - 1 - index])
    if (index % 2 === 1) {
      digit *= 2
      if (digit > 9) digit -= 9
    }
    sum += digit
  }
  return sum % 10 === 0
}

const validateCard = (value: unknown) =>
  typeof value === 'string' && value.length > 0 && !passesLuhn(value)
    ? 'Check the card number: a digit is wrong.'
    : null

export default function MaskedInputCardExample() {
  return (
    <Field.Root name="card" className="field" validationMode="onBlur" validate={validateCard}>
      <Field.Label>Card number</Field.Label>
      <MaskedInput.Root className="masked-input" mask={cardMask}>
        <MaskedInput.Input
          className="input masked-input-input"
          autoComplete="cc-number"
        />
        <MaskedInput.Guide className="masked-input-guide" />
      </MaskedInput.Root>
      <Field.Description className="muted">
        16 digits; 15 for a card that starts with 34 or 37.
      </Field.Description>
      <Field.Error className="error" match="customError" />
    </Field.Root>
  )
}

Expiry

An expiry date 99/99 with one message for an incomplete date and one for a month over 12.

MaskedInputExpiry.tsx
import { Field } from '@hamur/react/field'
import { MaskedInput } from '@hamur/react/masked-input'

const validateMonth = (value: unknown) =>
  typeof value === 'string' && value.length === 4 && !/^(0[1-9]|1[0-2])/.test(value)
    ? 'The month is 01 to 12.'
    : null

export default function MaskedInputExpiryExample() {
  return (
    <Field.Root name="expiry" className="field" validationMode="onBlur" validate={validateMonth}>
      <Field.Label>Expiry date</Field.Label>
      <MaskedInput.Root className="masked-input masked-input-short" mask="99/99">
        <MaskedInput.Input
          className="input masked-input-input"
          autoComplete="cc-exp"
        />
        <MaskedInput.Guide className="masked-input-guide" />
      </MaskedInput.Root>
      <Field.Description className="muted">Month and year, as on the card.</Field.Description>
      <Field.Error className="error" match="patternMismatch">
        Enter the month and the year.
      </Field.Error>
      <Field.Error className="error" match="customError" />
    </Field.Root>
  )
}

Code

A gift card code whose tokens upper-case letters, typed over with overwrite.

MaskedInputCode.tsx
import { Field } from '@hamur/react/field'
import { MaskedInput } from '@hamur/react/masked-input'

const tokens = {
  A: { pattern: /[A-Z0-9]/, transform: (char: string) => char.toUpperCase() },
}

export default function MaskedInputCodeExample() {
  return (
    <Field.Root name="gift-card" className="field">
      <Field.Label>Gift card code</Field.Label>
      <MaskedInput.Root
        className="masked-input"
        mask="AAAA-AAAA-AAAA"
        tokens={tokens}
        overwrite
      >
        <MaskedInput.Input
          className="input masked-input-input"
          autoComplete="off"
        />
        <MaskedInput.Guide className="masked-input-guide" />
      </MaskedInput.Root>
      <Field.Description className="muted">
        12 letters or digits, on the back of the card. Typing replaces the character after the
        cursor.
      </Field.Description>
    </Field.Root>
  )
}

Format On Blur

A phone number with formatOnBlur: raw digits while focused, formatted once the field is left.

MaskedInputFormatOnBlur.tsx
import { Field } from '@hamur/react/field'
import { MaskedInput } from '@hamur/react/masked-input'

export default function MaskedInputFormatOnBlurExample() {
  return (
    <Field.Root name="phone" className="field">
      <Field.Label>Phone number</Field.Label>
      <MaskedInput.Root
        className="masked-input"
        mask="(999) 999-9999"
        defaultValue="2125550123"
        formatOnBlur
      >
        <MaskedInput.Input
          className="input masked-input-input"
          autoComplete="tel-national"
        />
        <MaskedInput.Guide className="masked-input-guide" />
      </MaskedInput.Root>
      <Field.Description className="muted">
        10 digits. Type them without punctuation; the field adds it when you leave.
      </Field.Description>
    </Field.Root>
  )
}

Controlled

The raw value held by the app, set and cleared from outside, its formatted text from MaskedInput.format.

MaskedInputControlled.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { MaskedInput } from '@hamur/react/masked-input'

const mask = '(999) 999-9999'

export default function MaskedInputControlledExample() {
  const [value, setValue] = React.useState('212555')
  const [change, setChange] = React.useState('none yet')
  return (
    <div className="stack">
      <Field.Root name="phone" className="field">
        <Field.Label>Phone number</Field.Label>
        <MaskedInput.Root
          className="masked-input"
          mask={mask}
          value={value}
          onValueChange={(next: string, details: { reason: string; complete: boolean }) => { setValue(next) setChange(`${details.reason}, ${details.complete ? 'complete' : 'incomplete'}`) }}
        >
          <MaskedInput.Input
            className="input masked-input-input"
            autoComplete="tel-national"
          />
          <MaskedInput.Guide className="masked-input-guide" />
        </MaskedInput.Root>
        <Field.Description className="muted">10 digits, area code first.</Field.Description>
      </Field.Root>
      <div className="row">
        <button className="button" type="button" onClick={() => setValue('2125550123')}>
          Fill
        </button>
        <button className="button" type="button" onClick={() => setValue('')}>
          Clear
        </button>
      </div>
      <p className="muted masked-input-raw">Raw: {value || '(empty)'}</p>
      <p className="muted masked-input-formatted">
        Formatted: {MaskedInput.format(value, mask) || '(empty)'}
      </p>
      <p className="muted masked-input-change">Last change: {change}</p>
    </div>
  )
}

Form

A required phone number in a form, with a message for a missing and an incomplete value, and a reset.

MaskedInputForm.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'
import { MaskedInput } from '@hamur/react/masked-input'

export default function MaskedInputFormExample() {
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        setSubmitted([...new FormData(form)].map(([key, value]) => `${key}=${value}`).join('&'))
      }}
    >
      <Field.Root name="phone" className="field">
        <Field.Label>Phone number</Field.Label>
        <MaskedInput.Root
          className="masked-input"
          mask="(999) 999-9999"
          defaultValue="2125550123"
          required
        >
          <MaskedInput.Input
            className="input masked-input-input"
            autoComplete="tel-national"
          />
          <MaskedInput.Guide className="masked-input-guide" />
        </MaskedInput.Root>
        <Field.Description className="muted">10 digits, area code first.</Field.Description>
        <Field.Error className="error" match="valueMissing">
          Enter a phone number.
        </Field.Error>
        <Field.Error className="error" match="patternMismatch">
          Enter all 10 digits.
        </Field.Error>
      </Field.Root>
      <div className="row">
        <button className="button" type="submit">
          Send
        </button>
        <button className="button" type="reset" onClick={() => setSubmitted('')}>
          Reset
        </button>
      </div>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

API reference

23 props across 3 parts, in React syntax. className, style, render are listed once, under Shared props.

GuideMaskedInput.Guide

A template of the mask drawn behind or over the input: the typed text, kept invisible so it only takes its width, then the rest of the template with each empty slot drawn as placeholderChar. Hidden from assistive technology. Renders a <span>.

InputMaskedInput.Input

The text input whose text follows the mask. It is the field's labelled control; its value is the Root's. Renders an <input>.

Input props
PropTypeDefault
typeThe input's type.MaskedInputType'text'
inputModeThe virtual keyboard to show."search" | "text" | "none" | "tel" | "url" | "email" | "numeric" | "decimal"—
autoCapitalizeThe autocapitalize hint for virtual keyboards.string—
autoCorrectThe autocorrect hint for virtual keyboards.string'off'
spellCheckWhether the browser checks the spelling of the value.Booleanishfalse

RootMaskedInput.Root

Groups the masked input and its guide, holds the raw value, and renders the hidden input the form submits. Renders a <div>.

Root props
PropTypeDefault
maskrequiredThe mask: a string where 9 is a digit, a a letter, * either and \ makes the nextMaskedInputMaskProp—
tokensSlot characters to add or redefine, merged over 9, a and *.MaskedInputTokens—
valueThe raw value, without literals.string—
defaultValueThe raw value when the component is initially rendered, and the value a form reset restores.string''
onValueChangeCallback fired when the raw value changes.((value: string, eventDetails: MaskedInputRootChangeEventDetails) => void)—
onValueCompleteCallback fired after onValueChange when an edit fills the last slot, or when a paste gives((value: string, eventDetails: MaskedInputRootCompleteEventDetails) => void)—
onValueInvalidCallback fired when typed, pasted or dropped text has characters the mask rejects.((value: string, eventDetails: MaskedInputRootInvalidEventDetails) => void)—
normalizeValueNormalizes every incoming text before it is conformed to the mask: typed, pasted and dropped((text: string) => string)—
overwriteWhether typing replaces the slot character at the caret instead of inserting before it.booleanfalse
formatOnBlurWhether the input shows the raw value while focused and the formatted text otherwise.booleanfalse
placeholderCharThe character the guide draws in each empty slot.string'_'
formValueWhat the form submits: the raw value or the formatted text.MaskedInputFormValue'raw'
nameIdentifies the field when a form is submitted.string—
formThe id of the form element the hidden input belongs to.string—
requiredWhether the user must enter a value before submitting a form.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the value.booleanfalse
inputRefA ref to the hidden input that holds the form value.React.Ref<HTMLInputElement>—

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
Fill the next slot, with the literals before itInput
A character the slot does not take changes nothingInput
BackspaceDeletes the slot character before the caret, past the literals, and shifts the restInput
DeleteDeletes the slot character after the caret, past the literalsInput
ControlZMetaZUndoes the last edit; Meta+Z on Apple platformsInput
ControlShiftZControlYRedoes the edit undone lastInput

Accessibility

What the parts set
PartSetsSo that
Inputa plain input with no role, no placeholder and no aria-placeholder; value is the formatted textit is read as the text field it is, with the text the user sees
Inputaria-labelledby the Field label, aria-describedby its description and errors, aria-invalid, aria-requiredthe name, the format, the error and the rule are said with the field
Inputinputmode from the mask (numeric for digits only), autocapitalize characters for upper-cased letters, autocorrect off, spellcheck falsethe phone keyboard fits the slots, and nothing rewrites the text by itself
Guidearia-hidden="true", no rolethe slots still to fill are seen but never read
Rootthe hidden input with tabindex="-1", aria-hidden="true", name, required and the mask's patternthe form value and its validity never add a stop or a name

Focus order

  1. The Input is one tab stop; the Guide and the hidden input are never focused.
  2. Focus by Tab, by the label or by the app keeps the selection the browser puts there; nothing takes focus on mount or on a form reset.