Input Mask Engine
Token model, formatting modes, when a mask hurts, accessibility requirements
Overview
Form.Field.Phone, Form.Field.MaskedInput, and every Form.Document.* field run on a
framework-free mask engine (@letar/forms-core/mask) — no third-party dependency such as
use-mask-input/imask. Form.Field.CreditCard does not use this engine at all: it has its
own algorithmic grouping (@letar/forms-core/credit-card), which is not a mask in the sense of
this page.
This guide covers the engine's mechanics. For the ready-made Russian document fields (INN, OGRN, BIK, etc.) see Russian Documents.
Full example
The complete sandbox example, read directly from the form-develop-app source at build time —
phone masks for three countries plus passport/INN masked inputs.
'use client'
import { Box, Heading, VStack } from '@chakra-ui/react'
import { Form } from '@letar/forms'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'
/**
* Demo schema for masked fields
*/
const MaskedSchema = z.object({
// Phone fields
phoneRu: z
.string()
.min(1)
.meta({
ui: { title: 'Russian Phone', description: 'Format: +7 (999) 999-99-99' },
}),
phoneUs: z
.string()
.min(1)
.meta({
ui: { title: 'US Phone', description: 'Format: +1 (999) 999-9999' },
}),
phoneUk: z
.string()
.min(1)
.meta({
ui: { title: 'UK Phone', description: 'Format: +44 9999 999999' },
}),
// Masked input fields
passport: z
.string()
.min(1)
.meta({
ui: {
title: 'Passport (RU)',
description: 'Format: 99 99 999999',
},
}),
inn: z
.string()
.min(1)
.meta({
ui: {
title: 'INN',
description: 'Tax ID (12 digits)',
},
}),
snils: z
.string()
.min(1)
.meta({
ui: {
title: 'SNILS',
description: 'Format: 999-999-999 99',
},
}),
creditCard: z
.string()
.min(1)
.meta({
ui: { title: 'Credit Card', description: 'Format: 9999 9999 9999 9999' },
}),
expiryDate: z
.string()
.min(1)
.meta({
ui: { title: 'Expiry Date', description: 'Format: MM/YY' },
}),
cvv: z
.string()
.min(1)
.meta({
ui: { title: 'CVV', description: '3 or 4 digits' },
}),
})
type MaskedFormData = z.infer<typeof MaskedSchema>
const initialValues: MaskedFormData = {
phoneRu: '',
phoneUs: '',
phoneUk: '',
passport: '',
inn: '',
snils: '',
creditCard: '',
expiryDate: '',
cvv: '',
}
export default function MaskedDemoPage() {
const [submittedData, setSubmittedData] = useState<MaskedFormData | null>(null)
const handleSubmit = (data: MaskedFormData) => {
setSubmittedData(data)
}
return (
<DemoPageLayout title="Masked Fields Demo" description="Form.Field.Phone and Form.Field.MaskedInput" maxW="800px">
<Form initialValue={initialValues} schema={MaskedSchema} onSubmit={handleSubmit}>
<VStack gap={6} align="stretch">
{/* Phone Section */}
<Box>
<Heading size="md" mb={4}>
Phone Fields
</Heading>
<VStack gap={4} align="stretch">
<Form.Field.Phone name="phoneRu" country="RU" showFlag />
<Form.Field.Phone name="phoneUs" country="US" showFlag />
<Form.Field.Phone name="phoneUk" country="UK" showFlag />
</VStack>
</Box>
{/* Documents Section */}
<Box>
<Heading size="md" mb={4}>
Documents
</Heading>
<VStack gap={4} align="stretch">
<Form.Field.MaskedInput name="passport" mask="99 99 999999" />
<Form.Field.MaskedInput name="inn" mask="999999999999" />
<Form.Field.MaskedInput name="snils" mask="999-999-999 99" />
</VStack>
</Box>
{/* Payment Section */}
<Box>
<Heading size="md" mb={4}>
Payment
</Heading>
<VStack gap={4} align="stretch">
<Form.Field.MaskedInput name="creditCard" mask="9999 9999 9999 9999" />
<Form.Field.MaskedInput name="expiryDate" mask="99/99" />
<Form.Field.MaskedInput name="cvv" mask="999" />
</VStack>
</Box>
<Form.Button.Submit>Submit</Form.Button.Submit>
</VStack>
</Form>
<SubmittedDataPreview data={submittedData} />
</DemoPageLayout>
)
}shadcn-вариант этого примера ещё не готов.
Vue-пример для этого поля появится позже.
Angular-пример для этого поля появится позже.
Token model
A mask is a string of tokens and literals. Three built-in tokens:
| Token | Meaning |
|---|---|
9 | digit |
a | letter (Latin/Cyrillic) |
* | letter or digit |
Everything else in the pattern is a literal — inserted automatically, not directly editable (hyphens, spaces, parentheses).
SNILS 999-999-999 99
Russian dept. code 999-999
Russian passport 99 99 999999Custom tokens
The three built-in tokens are not enough where a field needs its own character alphabet or a
character transform on input (e.g. Latin-to-Cyrillic substitution for a Russian license plate).
MaskOptions.customTokens accepts a token with a pattern (which characters are accepted) and
an optional transform (how to rewrite the character before it is written):
const plateNumberTokens = {
l: {
pattern: (char: string) => 'АВЕКМНОРСТУХABEKMHOPCTYX'.includes(char),
transform: (char: string) => LATIN_TO_CYRILLIC[char.toUpperCase()] ?? char.toUpperCase(),
},
}Custom tokens cannot override the built-in ones (9/a/*).
Variable length and mask selection
mask is not necessarily a single string:
mask: string | string[] | ((raw: string) => string | null)- Array — the engine picks whichever variant the raw value decomposes into best (most accepted characters). Example: a license plate region code that is 2 or 3 digits long — two patterns in the array.
- Function — the mask depends on the value itself (e.g. a phone number: decide from the
first digit whether
8is a valid prefix or an error).nullmeans "no mask applies to this input", and the field behaves as a free-text input.
Formatting modes (formatMode)
formatMode:
| 'live' // mask applied on every keystroke — DEFAULT
| 'blur' // formatting applied on blur, stripped again on focus
| 'off' // token-alphabet filtering only, no grouping'live' is this library's default (a deliberate choice, not the industry-consensus
recommendation — see
MASK_ENGINE.md §6.6
and §8 for the research context). Live mode has a cost: the DOM controller owns caret placement
across three distinct branches (insert / delete-backward / delete-forward), an IME-composition
guard, autofill detection, and its own undo stack — without those a live mask breaks the
browser's native Ctrl+Z and makes the cursor jump. All of this already lives in
MaskController, not left for field authors to reimplement.
'live' mode technically produces an uncontrolled <input> (ref+defaultValue, no
value/onChange) — the DOM is the source of truth, and the controller writes into it via
setRangeText(), which preserves the browser's native undo stack. If you build a custom field
on top of useMaskField, account for this in your markup — passing value+onChange together
with 'live' conflicts with the controller's character-by-character writes.
When a mask hurts
Criterion from the Kontur design system, confirmed by the analysis in MASK_ENGINE.md §5.3:
Show a mask when a fixed number of characters is expected and the format includes separators. If the character count can vary, don't show a mask.
In practice: fixed length + separators → mask. Variable length → the mask hurts (truncation risk on paste/autofill, not an aid).
Concrete decisions this produced for the ready-made fields:
| Field | Mask? | Why |
|---|---|---|
Form.Document.SNILS | yes | fixed length, has separators |
Form.Document.DepartmentCode | yes | 999-999, fixed length |
Form.Document.INN | no | 10 or 12 digits — variable length, no separators |
Form.Document.BirthCertificate | no | the Roman-numeral part is variable length (1–5 chars) — a fixed mask can't express it |
Fields without a mask (INN, birth certificate) don't attach the engine at all — only
normalization and checksum validation through zRu schemas.
Accessibility requirements
formatMode: 'live' without these requirements reproduces the WCAG 3.3.3 failure found during
USWDS's own usability testing (4 of 5 screen-reader participants didn't understand that a
character had been rejected). That's why in Form.Field.MaskedInput they are not optional:
formatDescriptionis required (WCAG 3.3.2 — the format must be known before typing starts). Missing it triggers aconsole.errorin every build, not only dev (NODE_ENVis never used as a gate — see the repo rule against that anti-pattern).- A rejected character is announced via
aria-live="polite"— never"assertive". On by default, cannot be turned off through the API. - The mask template never leaks into
value. Otherwise a screen reader reads placeholder underscores as field content, and Chrome autofill won't offer a suggestion (the field looks non-empty). type="text"+inputmode="numeric"for digit masks, nevertype="number"— a numeric input has no Selection API, which the caret logic depends on.
<Form.Field.MaskedInput
name="departmentCode"
label="Department code"
mask="999-999"
formatDescription="Format: 3 digits, hyphen, 3 digits"
/>Building a custom field on the engine
The ready-made document fields are built on createDocumentField, a thin factory over
useMaskField (see Russian Documents for an example). For
fields outside Form.Document.*, use Form.Field.MaskedInput directly, or the useMaskField
hook from @letar/forms-react if you need fully custom markup.
Live example
Try the interactive example at forms-example.letar.best.