Masked input
canonStructured value typed straight into a visible mask (IČO, DIČ, PSČ, rodné číslo) with inline check-digit validation; input holds the typed text, a muted ghost draws the rest of the template.
Merged to origin/main 28 September 2026 (acrm MR !4082, "fix(web): IČO, DIČ, PSČ a rodné číslo jako maska (MaskedInput v @acrm/ui)"), under the owner's binding rule of 14 Sep 2026 (".claude/rules/design-system.md" §"Vstupy a mobil"): a structured value is a mask, never free text — only valid characters, separators added automatically, the template stays visible, the error is reported while typing. Unlike "masked-date-input", this component IS exported from @acrm/ui (source=package) and ships four masks as pure functions in `field-masks.ts`: IČO (8 digits, mod-11 check digit), DIČ (digits-first = Czech, "CZ" is added automatically; letters-first = the typed country code, CZ is never forced; mod-11 check only for CZ), PSČ ("### ##", Czech/Slovak only — a foreign address falls back to a plain Input in the host), rodné číslo ("######/####" or 9 digits before 1953, date part validated per segment, +50 on the month for women, 10-digit form checked mod 11 with the pre-1985 exception). Two host shapes, no new look: `variant="field"` (default) wraps `.acrm-inputwrap` (`.acrm-mask`/`.acrm-mask-ghost` inside, `.acrm-maskwrap.is-error` colours the typed text `--critical-ink` in addition to the wrapper's usual red border+ring) — e.g. IČO/DIČ in the "Nová firma" drawer (`FirmyPage.tsx`); `variant="formless"` reuses the exact `.fl-mask`/`.fl-mask-in`/`.fl-mask-ghost`/`.fl-mask.bad` classes `masked-date-input` already established in `dialog-formless.css` — e.g. rodné číslo in the "Nová osoba" dialog (`NewContactDialog.tsx`). Save gate: the host reads the value out only when `mask.complete(text)` is true (`ico: mask.complete(f.ico) ? f.ico : null`); an incomplete or invalid field is simply not saved, it never blocks the rest of the form (AD 28 Sep 2026) — same rule as `masked-date-input`'s ISO gate. An old invalid stored value (recorded before the mask existed) is loaded through `applyMask()` and shown as-is, editable, not rejected. `describedBy` links the error text via `aria-describedby` when the caller passes an id — the formless caller (`NewContactDialog`, rodné číslo) wires it; the field-variant callers in `FirmyPage` (IČO, DIČ, PSČ) do not, leaving the same label/error linking gap `text-field/input.a11y.md` documents for `<Field>`. Sibling pattern `MaskedPhoneInput` (phone, dial codes) moved into `packages/ui` 28 September 2026 (acrm MR !4085) alongside the new `PhoneInput` form field and `DialCodePicker`; it now has its own registry card, `phone-input`.
Used in 2 files · 2 areas · 2× — components/org, components/person
@acrm/ui <MaskedInput>.acrm-maskwrap .acrm-maskwrap.is-error .acrm-mask .acrm-mask-ghost +4SourceStoryA structured value typed straight into a visible mask, with check-digit validation reported while the user types. Component MaskedInput (packages/ui/src/components/MaskedInput.tsx); the masks themselves are pure functions in packages/ui/src/lib/field-masks.ts — the component only imports maskInput/maskRest and renders. Merged to origin/main 28 September 2026 (acrm MR !4082, "fix(web): IČO, DIČ, PSČ a rodné číslo jako maska (MaskedInput v @acrm/ui)"), under the owner's binding rule of 14 Sep 2026 (.claude/rules/design-system.md §"Vstupy a mobil"): a structured value is a mask, never free text — the field accepts only valid characters, separators are added automatically, the template stays visible, and the error is reported while typing, not only on blur.
Unlike masked-date-input (app-only, apps/web), this component is exported from @acrm/ui (source: package) and ships six masks out of the box: IČO, DIČ, PSČ, rodné číslo, SPZ and VIN (SPZ and VIN since acrm !4534, 3 Oct 2026).
When to use
- IČO (company ID) — 8 digits, mod-11 check digit validated once all 8 are typed.
- DIČ (VAT ID) — digits typed first are treated as Czech and
CZis prefixed automatically; letters typed first are the country code as-is (ATU12345678,SK…,DE…) — CZ is never forced. The mod-11 check only runs forCZ(Czech VAT ID =CZ+ a valid IČO). - PSČ (postal code) —
### ##, Czech/Slovak addresses only. A foreign address is free text — render a plain Input in the host, not this mask. - SPZ / RZ (vehicle registration plate) — capitals, digits and one space between groups, at most 10 characters (
1ab 2345!→1AB 2345). No fixed template: Czech and foreign plates differ in shape, so the mask only keeps valid characters. Complete from 2 characters. - VIN — 17 capitals and digits without I, O and Q (ISO 3779 — they read as 1 and 0); the rest shows as
_. The check digit is not validated (European VINs do not use it). An unfinished VIN is not an error while typing — the host reports it after the field is left. - Rodné číslo (birth number) —
######/####(9 digits before 1953, otherwise 10), date part validated per segment as it is typed, +50 added to the month for women, 10-digit form checked mod 11 with the pre-1985 zero-remainder exception. - Any other short structured value with a fixed character template and a mechanically checkable digit — add a new
FieldMasktofield-masks.tsrather than inventing a new component.
When not to use
- A date — use
masked-date-input(dd.mm.rrrr, its own segment/rollover logic); this component's masks are IČO/DIČ/PSČ/RČ only. - A phone number — use
phone-input(PhoneInputas a form field,MaskedPhoneInputas the Multi-value editor row); both moved intopackages/uiand got their own registry card in acrm MR !4085 (28 Sep 2026). - Free text of any length — use
input. - A value picked from a closed list — use
select/combobox, not a mask.
Anatomy
Measured from MaskedInput.tsx and field-masks.ts on origin/main.
- Two host shapes, no new look:
variant="field"(default) — the input sits inside.acrm-inputwrap.acrm-maskwrap, the same frame, focus ring and.is-errortreatment asinput. Example: IČO/DIČ in the "Nová firma" drawer (FirmyPage.tsx).variant="formless"— a form-less dialog row, reusing the exact.fl-mask/.fl-mask-in/.fl-mask-ghost/.fl-mask.badclassesmasked-date-inputalready established indialog-formless.css. Example: rodné číslo in the "Nová osoba" dialog (NewContactDialog.tsx).- Input holds the typed text (
value); a muted "ghost" span sits behind/under it and draws the rest of the template (maskRest) in--fg-muted, plus the already-typed part incolor: transparentso it reserves space and the two lines up character for character — same ghost pattern asmasked-date-input. An empty field showsplaceholder(or, if none given, the template itself as underscores) as the ghost. fieldvariant error state:.acrm-maskwrap.is-errordraws the usual red border + 16% ring on the wrapper (.acrm-inputwrap.is-error) and colours the typed text--critical-inkvia.acrm-maskwrap.is-error .acrm-mask > input.formlessvariant error state:.fl-mask.bad .fl-mask-incolours the typed text--critical-inkonly — no border, same asmasked-date-input's formless row (the row itself has no frame to redraw).- Backspace across an automatic separator deletes the digit before it too (
maskInput): the shown text is diffed against the raw input, and if only a separator is missing, the preceding character is dropped as well — otherwise that separator could never be deleted by the user. - Template syntax in
field-masks.ts:#= digit,A= letter (upper-cased), anything else is a separator the mask inserts and swallows if the user typed it themselves. DIČ has a variable template (apply/restinstead of a fixedtemplate) because its shape depends on the detected country code.
Best practices
- Read the mask's own
error(text)for the inline message andcomplete(text)to gate Save/lookup actions — do not re-derive validity in the host. - Only send the value to the server once
mask.complete(text)is true (ico: mask.complete(f.ico) ? f.ico : null— seeFirmyPage.tsx/NewContactDialog.tsx); an incomplete or invalid masked field is simply not saved, it never blocks the rest of the form (AD 28 Sep 2026) — same rule asmasked-date-input's ISO save gate. - Load a previously stored value through
applyMask(value, mask)so an old value that predates the mask (or was entered with different separators) re-renders correctly; if it is invalid, show it as-is and let the user edit it — do not reject or blank it on load. - Pass
describedBypointing at the rendered error element'sidso the error is linked witharia-describedby, not just shown visually next to the field (see Accessibility). - Use
variant="field"inside a labelled<Field>/<DrawerField>; usevariant="formless"only inside a form-less dialog row, matchingmasked-date-input's own row pattern.
- Show the incomplete-value message before the field has been touched/blurred —
error(text)itself only reports a genuinely wrong value (e.g. a bad check digit once all digits are typed); "not finished yet" is a host-level message shown only after blur, same pattern asmasked-date-input. - Force
CZonto a DIČ the user is typing with a different country's letters — the mask only addsCZwhen the user's first character is a digit. - Reimplement the mask's segmenting or check-digit logic in the host —
field-masks.tsis the only source of truth. - Block saving the rest of the form because one masked field is incomplete — see the save-gate rule above.
Content
- The ghost template shows underscores for undecided digits, never a fake example value.
placeholderon thefieldvariant is the field's own label text (e.g. "IČO"), shown as the ghost of an empty field — not a hint sentence.- Error text is one sentence naming what's wrong: "Neplatné IČO (kontrolní číslice nesedí)", "Neplatné rodné číslo (měsíc)". The "not yet complete" message is a separate host string ("IČO má 8 číslic") shown only once the field has been left.
Related
input— the plain single-line field this mask specialises; shares.acrm-inputwrap.masked-date-input— the date-mask sibling (dd.mm.rrrr), same ghost/formless pattern, no@acrm/uiexport yet.field— the label/hint/error wrapper used around thefieldvariant.date-field— when the value is a date the user mostly picks, not types.- Stories:
components--masked-input--default,components--masked-input--variants,components--masked-input--states,components--masked-input--composition.
Source: docs/text-field/masked-input.usage.md · GitLab