MaskedInput
Fields that format as you type — phone, money, card number and your own masks.
Qiymat: ""
Qiymat: 1250000
Qiymat: ""
Qiymat: ""
'use client';
import { CardInput, Field, MaskedInput, MoneyInput, PhoneInput } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function MaskedInputOverview() {
const [phone, setPhone] = useState('');
const [sum, setSum] = useState<number | null>(1250000);
const [card, setCard] = useState('');
const [passport, setPassport] = useState('');
return (
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
<Field label="Telefon" description={`Qiymat: "${phone}"`}>
<PhoneInput value={phone} onValueChange={setPhone} />
</Field>
<Field label="Summa" description={`Qiymat: ${sum ?? 'null'}`}>
<MoneyInput value={sum} onValueChange={setSum} />
</Field>
<Field label="Karta raqami" description={`Qiymat: "${card}"`}>
<CardInput value={card} onValueChange={setCard} />
</Field>
<Field label="Pasport" description={`Qiymat: "${passport}"`}>
<MaskedInput
mask="AA 9999999"
uppercase
placeholder="AB 1234567"
value={passport}
onValueChange={setPassport}
/>
</Field>
</div>
);
}
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.
| Component | Display | Value (value) |
|---|---|---|
PhoneInput | +998 (90) 123-45-67 | "901234567" |
MoneyInput | 1 250 000 so'm | 1250000 (number) |
CardInput | 8600 1234 1234 1234 UZCARD | "8600123412341234" |
MaskedInput | AB 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 anumber. - 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,disabledandreadOnlywork 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
'use client';
import { Field, PhoneInput, type PhoneInputDetails } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function PhoneInputDemo() {
const [phone, setPhone] = useState('');
const [details, setDetails] = useState<PhoneInputDetails | null>(null);
const badOperator = phone.length >= 2 && !details?.operator;
return (
<div className="flex w-full max-w-xs flex-col gap-4">
<Field
label="Telefon raqami"
required
error={badOperator ? `"${phone.slice(0, 2)}" — bunday operator kodi yo'q` : undefined}
description={
details?.valid ? `Operator: ${details.operator}` : '+998 90 123 45 67 ni joylang'
}
>
<PhoneInput
value={phone}
onValueChange={(value, info) => {
setPhone(value);
setDetails(info);
}}
/>
</Field>
<PhoneInput aria-label="Kichik" size="sm" defaultValue="991234567" />
<PhoneInput aria-label="Faqat o'qish" readOnly defaultValue="935554433" />
</div>
);
}
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}
'use client';
import { Field, MoneyInput } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function MoneyInputDemo() {
const [price, setPrice] = useState<number | null>(null);
return (
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
<Field label="Narx" description={`Qiymat: ${price ?? 'null'}`}>
<MoneyInput value={price} onValueChange={setPrice} clearable />
</Field>
<Field label="Kurs" description='decimals={2}, currency="$"'>
<MoneyInput decimals={2} currency="$" defaultValue={12.5} />
</Field>
<Field label="Oylik to'lov" description="100 000 dan 50 000 000 gacha">
<MoneyInput min={100000} max={50000000} />
</Field>
<Field label="Valyutasiz" description="currency={null}">
<MoneyInput currency={null} defaultValue={980000} />
</Field>
</div>
);
}
decimals— number of decimal places (default0). A typed comma becomes a dot.max— values above it can't be typed (the keystroke is rejected).min— smaller values snap up tominwhen the field loses focus.currency={null}hides the currency; passcurrency="$"or anyReactNodeto change it.
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
'use client';
import { CardInput, Field, getCardDetails } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function CardInputDemo() {
const [card, setCard] = useState('');
const { complete, valid, brand } = getCardDetails(card);
return (
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
<Field
label="Karta raqami"
error={complete && !valid ? "Karta raqami noto'g'ri" : undefined}
description={
valid ? `Karta to'g'ri · ${brand ?? 'boshqa'}` : "Sinab ko'ring: 4111 1111 1111 1111"
}
>
<CardInput value={card} onValueChange={setCard} />
</Field>
<div className="flex flex-col gap-3">
<CardInput aria-label="Uzcard" defaultValue="8600123412341234" />
<CardInput aria-label="Humo" defaultValue="9860010203040506" />
<CardInput aria-label="Luhn xato" defaultValue="4111111111111112" />
</div>
</div>
);
}
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="****-****"
'use client';
import { Field, MaskedInput } from '@akhatmirzo/ui';
export default function MaskedInputCustom() {
return (
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
<Field label="Avtomobil raqami" description='mask="99 A 999 AA"'>
<MaskedInput mask="99 A 999 AA" uppercase placeholder="01 A 123 BC" />
</Field>
<Field label="JShShIR" description="14 ta raqam">
<MaskedInput mask="99999999999999" inputMode="numeric" placeholder="14 raqam" />
</Field>
<Field label="Vaqt" description='mask="99:99"'>
<MaskedInput mask="99:99" inputMode="numeric" placeholder="09:30" />
</Field>
<Field label="Promo-kod" description='mask="****-****"'>
<MaskedInput mask="****-****" uppercase placeholder="XXXX-XXXX" />
</Field>
</div>
);
}
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
| Prop | Type | Default | Description |
|---|---|---|---|
maskrequired | string | — | 9 — digit, A — letter, * — any; everything else is fixed |
value | string | — | Clean value without fixed characters: "AB1234567" |
defaultValue | string | — | |
onValueChange | (value: string, details: MaskedInputDetails) => void | — | details: { formatted, complete } |
uppercase | boolean | false | Convert letters to upper case |
PhoneInput
prefix isn't accepted — it's always +998.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | 9 digits without +998: "901234567" |
defaultValue | string | — | |
onValueChange | (value: string, details: PhoneInputDetails) => void | — | details: { valid, operator, complete } |
validateOperator | boolean | true | Mark the field invalid when the operator code is unknown |
placeholder | string | '(90) 123-45-67' |
MoneyInput
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | — | |
defaultValue | number | null | — | |
onValueChange | (value: number | null) => void | — | |
currency | ReactNode | "so'm" | Currency on the right; null hides it |
decimals | number | 0 | Decimal places |
min | number | — | Smaller values snap to min on blur |
max | number | — | Larger values can't be typed |
placeholder | string | '0' |
CardInput
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | 16 digits: "8600123412341234" |
defaultValue | string | — | |
onValueChange | (value: string, details: CardInputDetails) => void | — | details: { brand, complete, valid } |
validateLuhn | boolean | true | Mark the field invalid when a complete number fails Luhn |
renderBrand | (brand: CardBrand) => ReactNode | — | Render the brand mark yourself |
leftIcon | ReactNode | — | Defaults to a card icon |
placeholder | string | '0000 0000 0000 0000' |
CardBrand is 'uzcard' | 'humo' | 'visa' | 'mastercard' (from @akhatmirzo/core).
Data attributes
| Attribute | Component | Value |
|---|---|---|
data-mask | MaskedInput | the mask string |
data-brand | CardInput | uzcard · humo · visa · mastercard |
Accessibility
- A native
<input type="text">— screen readers read the formatted text, and editing works as usual. - The right mobile keyboard:
PhoneInputusesinputMode="tel",CardInputandMoneyInputusenumeric(decimalwhendecimalsis set). - Autofill:
PhoneInputsetsautoComplete="tel-national",CardInputsetsautoComplete="cc-number".MaskedInputturns autofill and spellcheck off. - Invalid state from
validateOperatorandvalidateLuhnis exposed asaria-invalid. Explain the problem in text through theFielderrorprop — 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").