Mask Input

A single text field that formats as the user types — money in the workspace’s currency, percentages, IDs with a fixed shape — and hands you two values every time: the formatted text the user sees, and the raw value you actually store.

Import
import { MaskInput } from '@comitor/ui' — the main entry, with no optional peer to install. MaskInput is a client component; the pattern table and the six helpers beside it (MASK_PATTERNS, applyCurrencyMask…) are plain functions a Server Component can call.
Origin
Added in 2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Input styling and tokens. Where it behaves differently from upstream is listed under What the port guarantees.
Changed in 3.0.0
currency defaults to USD (2.1.0: VND) and the number format follows the provider’s locale (2.1.0: always vi-VN). A VND workspace passes currency="VND" from its settings; a Vietnamese app gets its 250.000.000 ₫ back from <ComitorLocaleProvider locale="vi">. Details in the changelog.

Anatomy

There are no parts: MaskInput is one native <input> with keystroke, paste and composition handlers attached. Everything interesting lives in the pattern — a string in which # takes one character from the user and every other character is inserted, plus two optional functions that say how raw text becomes a value and when that value is acceptable.

tsx
import { MaskInput, type MaskPattern } from '@comitor/ui'

// One element: a native <input>. No parts, no hidden input, no strings of its own.
<MaskInput
  mask="currency"          // a MASK_PATTERNS key, or a MaskPattern of your own
  currency="VND"           // ISO 4217 — a region setting, from the workspace
  value={amount}           // masked or unmasked: either is re-masked on render
  onValueChange={(masked, unmasked) => setAmount(unmasked)}
  onValidate={(isValid, unmasked) => setValid(isValid)}  // only if the pattern has validate
/>

// What a pattern is:
interface MaskPattern {
  // '#' takes one character from the user; anything else is inserted as typed.
  pattern: string
  // Raw text → unmasked value. Leave it out and only digits are kept.
  transform?: (value: string, opts?: { currency?: string; locale?: string }) => string
  // Runs on the unmasked value. min / max come from the props of the same name.
  validate?: (value: string, opts?: { min?: number; max?: number }) => boolean
}

Money in the workspace currency

onValueChange hands you both values on every keystroke and every paste: ₫250,000,000 for the screen and 250000000 for the database. Keep only the second — value accepts it and re-masks it on every render, so there is one source of truth instead of two strings to keep in step. Store it together with its currency; an amount without one is not a price. For a currency with cents, the unmasked value uses a dot and up to two decimals whatever the reader’s locale — 1234.5, never 1234,5.

masked = "₫250,000,000"

unmasked = "250000000" → store with currency "VND"

Typing 1234567 here walks ₫1,234 → ₫12,345 → ₫123,456 → ₫1,234,567: every digit is a whole đồng, because the field reads each currency’s own number of decimals — none for VND and JPY. Upstream assumed two for every currency, and guessed which separator was the decimal point; see What the port guarantees for what that did to VND, and to USD and EUR.

Percentage

mask="percentage" keeps digits and one decimal mark, allows two decimals and adds the sign. The mark is the locale’s: under vi, de, fr and the other comma-decimal locales the field reads a comma or a dot — the dot because a phone’s numeric keypad follows the device’s language, not the app’s — and shows 12,5%, while the unmasked value stays "12.5", which shows as 12,5% again when passed back as value. Its validate checks a range — 0 to 100 unless min / max say otherwise — and reports through onValidate. Reporting is all it does: the field turns red because the app passes invalid, which also writes aria-invalid. Type 12,5 under vi, then under en: English reads only a dot, as upstream, so there the comma is dropped, the value becomes 125%, and only the range check catches it.

Language — ComitorLocaleProvider

Up to 30%.

masked = "12.5%" · unmasked = "12.5"

valid = true

Reading the comma is new in 3.1.0. Until then every locale read only a dot, and apps put a “use a dot” hint beside the field — that hint can go. The unmasked value has not changed, but under vi or de the field’s text has: a test that expects 12.5% there now finds 12,5%.

A pattern of your own

Most identifiers a Comitor app handles are not in the built-in table, and several of the built-ins are American. A Vietnamese company’s tax code is ten digits, with three more after a hyphen for a dependent branch — one pattern string and a one-line validate. Two details a custom pattern does not get for free: a mobile keypad (pass inputMode="numeric") and a sensible moment to judge the value — validationMode="onBlur" waits until the user leaves the field instead of flagging every half-typed code.

10 digits, or 13 for a branch — the hyphen is added for you.

masked = "" · unmasked = ""

valid = not checked yet (onBlur)

The 15 built-in patterns

Read straight from MASK_PATTERNS — the set upstream ships, nothing added or removed. Six are United States formats, kept so the API matches upstream; in a Comitor app phone numbers go through Phone Input and dates through Date Picker.

maskpatternUSNotes
phone(###) ###-####USExactly 10 digits. Phone numbers belong to PhoneInput.
ssn###-##-####USSocial Security number.
date##/##/####USMM/DD/YYYY — validate reads the month first. Dates belong to DatePicker.
time##:##—24-hour HH:MM, validated up to 23:59. For a picker, TimePicker.
creditCard#### #### #### ####—Up to 16 digits in fours; validate runs the Luhn check.
creditCardExpiry##/##—MM/YY; validate rejects a month already past.
zipCode#####USZIP code.
zipCodeExtended#####-####USZIP+4.
currency$###,###.##—Only a marker. Formatting goes through Intl.NumberFormat with currency and locale.
percentage##.##%—Two decimals at most, written with the locale’s decimal mark; the unmasked value uses a dot. validate checks 0–100, or min and max.
licensePlate###-###—Six letters or digits, upper-cased.
ipv4###.###.###.###—Keeps digits and dots and inserts nothing — the one pattern that adds no characters.
macAddress##:##:##:##:##:##—Twelve hex characters in pairs, upper-cased.
isbn###-#-###-#####-#—ISBN-13 with hyphens.
ein##-#######USEmployer Identification Number.

In a form

Unlike the pickers from the same port, MaskInput has no hidden input: it is the input. Give it a name and the form posts exactly what the user sees — ₫48,000,000, in whatever format the reader’s locale produced. That is rarely what a server wants, so post the unmasked value beside it (or instead of it) from state. Press the button to see all three fields arrive.

FormData → press Post invoice

Formatting on the server

Upstream is one file. The port splits it: the component stays behind 'use client', while the pattern table and the helpers live in a module without the directive. That split is not cosmetic — every export of a 'use client' file reaches a Server Component as a reference that reads as undefined, without an error or a warning. Printing an amount into a table, an email or a PDF is exactly the server-side job these helpers are for. They take currency and locale as arguments and never read the provider.

app/deals/[id]/page.tsx
// A Server Component, an email template, a PDF job: the helpers live in a module
// WITHOUT 'use client', so they run anywhere — not just behind the client boundary.
import { applyCurrencyMask, getUnmaskedValue, MASK_PATTERNS } from '@comitor/ui'

const money = { currency: workspace.currency, locale: user.locale } // { currency: 'VND', locale: 'vi' }

applyCurrencyMask({ value: deal.amount, ...money }) // '250.000.000 ₫'

// Reading back a masked string that arrived from a plain form post:
getUnmaskedValue({ value: '250.000.000 ₫', transform: MASK_PATTERNS.currency.transform, ...money })
// '250000000'

// The helpers never read ComitorLocaleProvider. Leave currency and locale out and
// you get upstream's defaults, USD and en-US:
applyCurrencyMask({ value: '250000000' }) // '$250,000,000'

What the port guarantees

  • Zero-decimal currencies stay whole. Each currency’s natural number of decimals comes from Intl — none for VND and JPY, two for USD and EUR — and in a zero-decimal currency every dot and comma is a group separator. Measured on upstream’s code, typing 1234567 in VND went 1.234 ₫ → 1.234,5 ₫ → 1.234,56 ₫ → 1.234.567 ₫ and reported "1234.5" on the way; now it is 12.345 ₫ → 123.456 ₫ and the unmasked value is digits only. No dangling 1.234, ₫ either.
  • The decimal separator is the locale’s. In a currency with decimals, upstream guessed the decimal point from how many digits followed a separator: deleting the last digit of $123,456 gave $123.45, and typing 12345 in USD under vi stored "1234.5". Now, when both . and , appear, the last one is the decimal; when only one does, the locale’s decimal separator is the decimal and its group separator only ever groups — except a . typed at the very end in a comma-decimal locale (the numeric keypad’s key), which is read as the decimal. The unmasked value you store and pass back as value — "12345.56" — is read as it is. The cost is deliberate: 1234,5 pasted into an en field becomes $12,345, because the comma groups in English; the result shows in the field, where it can be corrected. Fixed in 3.0.1.
  • A percentage takes the locale’s decimal mark too. Upstream’s percentage read only a dot, so under vi 12,5 became 125% — ten times the discount. In a comma-decimal locale it now reads a comma or a dot and shows 12,5%; the unmasked value keeps its dot. Fixed in 3.1.0.
  • A paste reaches the app. In a currency or percentage field, upstream’s paste handler set the caret and returned before reporting: the field showed the pasted number, but onValueChange never ran, the next render put the old number back, and a form posted the old value. A paste now reports exactly like typing. Fixed in 3.0.1.
  • The format is the reader’s. Upstream hard-coded en-US; the field follows ComitorLocaleProvider, or its own locale prop, for separators and the position of the symbol — the same value on the server and in the browser.
  • The helpers work outside the client. MASK_PATTERNS and the six functions are exported from a module without 'use client', so a Server Component gets the real functions rather than undefined.
  • It lines up with Input. Comitor’s Input classes replace shadcn’s — h-9, the border roles, hover, focus ring and a destructive-ink error border — so a masked field and a plain one in the same form match. aria-invalid is written only when invalid is true, and one supplied by a form library still wins.

Language and region

MaskInput has no words of its own — no namespace in the provider, no labels prop, no DEFAULT_*_LABELS table, nothing under comitorMessagesVi. The label and placeholder are yours to translate. What the language changes is how a number is written: group and decimal separators and where the symbol sits, from the provider’s locale or the locale prop. What it must never change is which money it is. Switch the two independently below; the stored row only changes when the currency does.

Language — ComitorLocaleProvider

Currency — workspace setting

shown = "₫250,000,000"

stored = { amount: "250000000", currency: "VND" }

  • The currency is a region setting. A Vietnamese salesperson who prefers the English interface still sells in VND; a Vietnamese-speaking team in a US workspace bills in USD. Pass currency from the workspace’s settings. 3.0.0 went back to upstream’s USD default precisely because no language can imply a currency.
  • The format is the locale, not just the language. en-GB writes US$12,500 where en writes $12,500. Set the locale once at the root — Set the language covers it — and override a single field with locale only when you have a reason to.
Storedenen-GBvi
250000000 VND₫250,000,000₫250,000,000250.000.000 ₫
12500 USD$12,500US$12,50012.500 US$
9800.75 EUR€9,800.75€9,800.759.800,75 €
1500000 JPY¥1,500,000JP¥1,500,0001.500.000 ¥

Computed on this page with applyCurrencyMask — the helper the field itself uses.

Known limitations

  • percentage reads a comma only where the locale writes one. Under en, 12,5 still becomes 125%, as upstream, and only the range check catches it. Under a comma-decimal locale the first mark is always the decimal, so a pasted number with a thousands separator is misread: 1.234,5 becomes 1,23%.
  • Six built-in patterns are US formats — phone, ssn, date, zipCode, zipCodeExtended, ein. date validates month first. Use Phone Input, Date Picker or your own pattern.
  • A custom pattern gets no keypad. inputMode is chosen only for built-in names; pass inputMode="numeric" yourself.
  • name posts the masked text. Post the unmasked value from state, or unmask on the server with getUnmaskedValue and the same currency and locale the field used.
  • validationMode="onSubmit" never calls onValidate. The component has no submit hook. Validate in your submit handler with the pattern’s own function — MASK_PATTERNS.percentage.validate?.(discount, { max: 30 }).
  • Three-decimal currencies are capped at two — KWD, BHD, OMR — because the rest of the logic only understands two.

Props

MaskInput

PropTypeDefaultDescription
mask'currency' | 'percentage' | 'creditCard' | … | MaskPattern—One of the 15 built-in pattern names (table above) or a MaskPattern of your own. Without a mask the component is a plain input.
valuestring—Controlled value. Masked or unmasked — either is unmasked and re-masked on render, so you can hold only the unmasked value.
defaultValuestring—Uncontrolled initial value.
onValueChange(masked: string, unmasked: string) => void—masked is what the field shows; unmasked is what you store — digits, plus a dot and up to two decimals for a currency or a percentage.
onValidate(isValid: boolean, unmasked: string) => void—Called only when the pattern has validate, at the moments validationMode picks. It reports; it does not set invalid.
validationMode'onChange' | 'onBlur' | 'onSubmit' | 'onTouched' | 'all''onChange'onTouched validates on blur until the first blur, then on every change. onSubmit never calls onValidate — the component has no submit hook.
currencystring'USD'ISO 4217 code for mask='currency'. A region setting: pass the workspace's currency. 2.1.0 defaulted to VND.
localestringprovider localeBCP 47 tag for the separators and where the symbol goes — and for the decimal mark of percentage, or of a pattern that reuses its transform. Without a provider, en. 2.1.0 hard-coded vi-VN, upstream en-US.
placeholderstring—Shown while the field is not focused.
maskPlaceholderstring—Replaces placeholder while the field is focused — a template such as __/__ for the user to fill.
minnumber | string—Handed to validate as opts.min — percentage uses it (0 by default). Also forwarded to the input, where it does nothing for a text field.
maxnumber | string—Same, as opts.max — percentage defaults to 100.
invalidbooleanfalseWrites aria-invalid and the destructive-ink border. Nothing sets it for you.
withoutMaskbooleanfalseSwitches masking off: the text passes through, and onValueChange gets it as both arguments.
inputModeHTMLAttributes['inputMode']per maskdecimal for currency, percentage and ipv4; numeric for the other digit-only built-ins; nothing for a custom pattern.
maxLengthnumberpattern lengthFor a # pattern the pattern's own length wins. Yours applies to currency, percentage and no mask.
asChildbooleanfalseRender your own input element (Radix Slot) with the masking handlers attached.
...restComponentProps<'input'>—id, name, disabled, readOnly, required and aria-* reach the input unchanged. name posts the masked text; required is native.

Pure helpers — safe in a Server Component

PropTypeDefaultDescription
MASK_PATTERNSRecord<MaskPatternKey, MaskPattern>—The 15 built-in patterns. Spread one to make your own variant.
applyMask({ value, pattern, currency?, locale?, mask? }) => string—Masks an unmasked value. A pattern containing $, € or %, or mask 'currency', goes to the currency or percentage formatter; a percentage takes the locale's decimal mark only when mask is 'percentage'.
applyCurrencyMask({ value, currency?, locale? }) => string—Intl formatting with the currency's own number of decimals (capped at two). Defaults to USD and en-US.
applyPercentageMask(value: string, opts?: { locale?: string }) => string—Up to two decimals and a % sign: '12.5' → '12.5%'. A comma-decimal locale writes a comma — ('12.5', { locale: 'vi' }) → '12,5%'. Without opts, a dot, as before 3.1.0.
getUnmaskedValue({ value, transform?, currency?, locale? }) => string—Runs the pattern's transform; without one, keeps digits only. For currency, pass the same currency and locale the field used.
toUnmaskedIndex({ masked, pattern, caret }) => number—A caret position in the masked text → the index in the unmasked value.
fromUnmaskedIndex({ masked, pattern, unmaskedIndex }) => number—The reverse — for your own caret handling in a custom input.

Accessibility

  • It is a native text input, so everything a plain input gets it gets: label it with a Label htmlFor and an id, or spread FormField’s control props onto it — id, aria-invalid, aria-required and aria-describedby all reach the element.
  • required is native here and blocks submission the usual way — unlike Time Picker and Phone Input, whose values travel in hidden inputs.
  • Mobile keypads follow the mask: decimal for currency, percentage and ipv4, numeric for the other digit-only built-ins, nothing for a custom pattern. A # pattern also sets maxLength, so typing past the end is refused rather than silently trimmed.
  • After every keystroke the caret goes back to just after the character typed, not to the end — correcting a digit in the middle of an amount works. Backspace and Delete in a pattern step over the inserted characters.
  • While an input method is composing text, masking waits and applies once, at compositionend — it does not reformat half-composed input under the IME.
  • Screen readers read the masked text, symbol and separators included — “250.000.000 ₫” — which is what the user sees.
  • maskPlaceholder shows only while the field is focused. Neither it nor placeholder is a label or a lasting hint: put the expected format in visible text and point aria-describedby at it, as the demos do.
  • Still the app’s job: the label, the hint and the error text, turning onValidate into invalid, and storing the unmasked value together with its currency.
  • Text is 16px below md and 14px from md up, like every field since 3.13.4 — 14px at every width before. Leave the size to the package; Input explains why.

Related

  • Phone Input — phone numbers, with a country and an E.164 value. Not mask="phone".
  • Date Picker and Time Picker — dates and times of day. Not mask="date" or mask="time".
  • Form Section — FormField’s render prop spreads its id and aria props straight onto a MaskInput.
  • CRM — the deal pipeline prints stored amounts with Intl.NumberFormat; this is the input side of the same number.
  • Commerce and HR — discounts at checkout, salaries in the payroll run: amounts and percentages that must be stored raw.