@akhatmirzo/ui

MaskedInput

Fields that format as you type — phone, money, card number and your own masks.

Qiymat: ""

+998

Qiymat: 1250000

Qiymat: ""

Qiymat: ""

Import

import { CardInput, MaskedInput, MoneyInput, PhoneInput } from '@akhatmirzo/ui';

All four share one rule: the value is clean, the display is formatted. The screen shows "(90) 123-45-67", while onValueChange gets "901234567". You never have to strip separators before saving, validating or sending the value to a server.

ComponentDisplayValue (value)
PhoneInput+998 (90) 123-45-67"901234567"
MoneyInput1 250 000 so'm1250000 (number)
CardInput8600 1234 1234 1234 UZCARD"8600123412341234"
MaskedInputAB 1234567 ("AA 9999999")"AB1234567"

When to use

  • For data with a fixed format — phone numbers, cards, passports, licence plates. Users don't type the separators, so there's less room for mistakes.
  • For amounts of money — MoneyInput. Thousands separators make big numbers readable, and the value is already a number.
  • Not for free text — names, addresses and comments stay in a plain Input. A mask only gets in the way when the length varies.
  • Always add a label — use a Field. The placeholder ("(90) 123-45-67") is just a hint, not a label.
  • Everything else comes from Input: size, invalid, clearable, leftIcon, disabled and readOnly work exactly as in Input.

The caret stays put

Formatting never makes the caret jump: delete or insert a digit in the middle and it stays where you are. Backspace skips the fixed characters (-, spaces, dots) and deletes the digit before them; Delete does the same going forward.

PhoneInput

An Uzbek phone number: +998 is a fixed prefix that can't be deleted, so users type 9 digits. Pasting "+998 90 123 45 67" also lands correctly. The operator code is checked (33, 50, 55, 77, 88, 90, 91, 93, 94, 95, 97, 98, 99); an unknown code marks the field invalid automatically.

+998 90 123 45 67 ni joylang

+998
+998
+998

The second argument of onValueChange holds the details: { valid, operator, complete }. Get the same data outside the component with getPhoneDetails(value) — handy for form validation.

import { getPhoneDetails } from '@akhatmirzo/ui';

getPhoneDetails('901234567'); // { valid: true, operator: '90', complete: true }
getPhoneDetails('121234567'); // { valid: false, operator: null, complete: true }

MoneyInput

Spaces as the thousands separator, currency on the right ("so'm" by default). The value is number | null. Phones show a numeric keypad (with a decimal point when decimals is set).

Qiymat: null

decimals={2}, currency="$"

100 000 dan 50 000 000 gacha

currency={null}

  • decimals — number of decimal places (default 0). A typed comma becomes a dot.
  • max — values above it can't be typed (the keystroke is rejected).
  • min — smaller values snap up to min when the field loses focus.
  • currency={null} hides the currency; pass currency="$" or any ReactNode to change it.

Amount in words

showWords spells the amount under the field: "Bir million ikki yuz ellik ming soʻm". It helps users double-check numbers in contracts, invoices and payment forms. The text comes from moneyToWords in @akhatmirzo/core and updates as you type.

Bir million ikki yuz ellik ming soʻm

Бир миллион икки юз эллик минг сўм

  • showWords={{ script: 'cyrillic' }} — in Cyrillic. Without script, the ScriptProvider script is used (Latin when there is no provider).
  • showWords={{ formal: true }} — formal style: "bir yuz", "bir ming".
  • Inside a Field, the words appear under the field, after the description, and are added to aria-describedby. The text sits in an aria-live="polite" region, so screen readers announce the new amount.

CardInput

A bank card: groups of four, with a brand mark on the right — UZCARD (8600), HUMO (9860), VISA (4) and Mastercard (51–55, 2221–2720). Once all 16 digits are in, the Luhn check runs; if it fails, the field becomes invalid.

Sinab ko'ring: 4111 1111 1111 1111

The brand mark isn't an official logo — it's a neutral text badge built from semantic tokens, so it reads well in dark mode too. Need the real logo? Draw it with renderBrand:

import { CardInput } from '@akhatmirzo/ui';

<CardInput renderBrand={(brand) => <img src={`/cards/${brand}.svg`} alt="" className="h-5" />} />;

getCardDetails(value) returns { brand, complete, valid }; the CardBrandMark component is exported as well (<CardBrandMark brand="humo" />).

Custom masks

MaskedInput is the general-purpose mask. 9 is a digit, A a letter, * any character except whitespace. Every other character is fixed — the user never types it. uppercase capitalises letters as they're typed.

mask="99 A 999 AA"

14 ta raqam

mask="99:99"

mask="****-****"

onValueChange(value, { formatted, complete }) — complete tells you every slot is filled. The root element carries a data-mask attribute.

In forms

Pass name and a hidden <input> with the clean value is added to the form — FormData gets "901234567" or "1250000", not the formatted text.

<form action="/api/order">
  <PhoneInput name="phone" required />
  <MoneyInput name="amount" />
</form>

Props

Every component accepts the Input props (size, invalid, clearable, leftIcon, disabled, readOnly, name, aria-* ...) and ref. type and value / defaultValue are their own.

MaskedInput

PropTypeDefaultDescription
maskrequiredstring—9 — digit, A — letter, * — any; everything else is fixed
valuestring—Clean value without fixed characters: "AB1234567"
defaultValuestring—
onValueChange(value: string, details: MaskedInputDetails) => void—details: { formatted, complete }
uppercasebooleanfalseConvert letters to upper case

PhoneInput

prefix isn't accepted — it's always +998.

PropTypeDefaultDescription
valuestring—9 digits without +998: "901234567"
defaultValuestring—
onValueChange(value: string, details: PhoneInputDetails) => void—details: { valid, operator, complete }
validateOperatorbooleantrueMark the field invalid when the operator code is unknown
placeholderstring'(90) 123-45-67'

MoneyInput

PropTypeDefaultDescription
valuenumber | null—
defaultValuenumber | null—
onValueChange(value: number | null) => void—
currencyReactNode"so'm"Currency on the right; null hides it
decimalsnumber0Decimal places
minnumber—Smaller values snap to min on blur
maxnumber—Larger values can't be typed
showWordsboolean | { formal?: boolean; script?: 'latin' | 'cyrillic' }falseSpell the amount in words under the field
placeholderstring'0'

CardInput

PropTypeDefaultDescription
valuestring—16 digits: "8600123412341234"
defaultValuestring—
onValueChange(value: string, details: CardInputDetails) => void—details: { brand, complete, valid }
validateLuhnbooleantrueMark the field invalid when a complete number fails Luhn
renderBrand(brand: CardBrand) => ReactNode—Render the brand mark yourself
leftIconReactNode—Defaults to a card icon
placeholderstring'0000 0000 0000 0000'

CardBrand is 'uzcard' | 'humo' | 'visa' | 'mastercard' (from @akhatmirzo/core).

Data attributes

AttributeComponentValue
data-maskMaskedInputthe mask string
data-brandCardInputuzcard · humo · visa · mastercard

Accessibility

  • A native <input type="text"> — screen readers read the formatted text, and editing works as usual.
  • The right mobile keyboard: PhoneInput uses inputMode="tel", CardInput and MoneyInput use numeric (decimal when decimals is set).
  • Autofill: PhoneInput sets autoComplete="tel-national", CardInput sets autoComplete="cc-number". MaskedInput turns autofill and spellcheck off.
  • Invalid state from validateOperator and validateLuhn is exposed as aria-invalid. Explain the problem in text through the Field error prop — color alone isn't enough.
  • The currency and the card brand are decorative (aria-hidden). If the currency matters, put it in the label ("Amount, so'm").

On this page