Text & Numeric Inputs
text, textarea, number, and currency fields, including masked input, locale-aware formatting, and the numeric stepper.
The four field types on this page cover free-text and numeric entry: text for single-line strings, textarea for multi-line strings, number for plain numbers, and currency for monetary amounts. All four share the same base config (label, description, placeholder, disabled, required, visible, validators) and add a few type-specific options.
text and textarea store a string. number and currency store a plain number and are built on the same masked-input + locale-formatting + stepper stack, described in detail below.
Every field config also accepts the shared base props — label, description, placeholder, colSpan, disabled, required, visible, validate, validateAsync, and validateAsyncDebounceMs. The tables below list only the props specific to each field type. label, description, and placeholder are i18n keys, resolved at render time.
text
Single-line text input. The stored value is a string.
| Prop | Type | Default | Notes |
|---|---|---|---|
type | "text" | — | Required discriminator. |
There are no text-specific config props beyond the shared base. The input is wrapped in an InputGroup so an async-validation spinner can appear inline on the trailing edge (see Async-validation spinner).
const schema = z.object({
title: z.string().min(3).max(80),
});
const fields: FormFields<typeof schema> = {
title: {
type: "text",
label: "Title",
description: "Between 3 and 80 characters.",
placeholder: "A short headline…",
},
};textarea
Multi-line text input. The stored value is a string.
| Prop | Type | Default | Notes |
|---|---|---|---|
type | "textarea" | — | Required discriminator. |
rows | number | browser default | Initial visible row count, passed straight through to the underlying <textarea>. |
const schema = z.object({
description: z.string().max(500),
});
const fields: FormFields<typeof schema> = {
description: {
type: "textarea",
label: "Description",
description: "Up to 500 characters of free-form notes.",
rows: 4,
},
};number
Numeric input. The stored value is a plain number (or undefined when empty).
| Prop | Type | Default | Notes |
|---|---|---|---|
type | "number" | — | Required discriminator. |
min | number | — | Lower bound. Clamps the stepper and disables the decrement button at the floor. UI hint only — keep hard validation in Zod. |
max | number | — | Upper bound. Clamps the stepper and disables the increment button at the ceiling. |
step | number | 1 | Increment applied by the arrow keys and the stepper buttons. |
const schema = z.object({
age: z.number().int().min(0).max(150),
});
const fields: FormFields<typeof schema> = {
age: {
type: "number",
label: "Age",
description: "Integer, 0–150. Use ↑/↓ or the stepper to adjust.",
min: 0,
max: 150,
step: 1,
},
};min / max here drive the stepper UX (clamping and button-disabling) — they do not enforce validation on typed input. A user can still paste an out-of-range value; the Zod schema is what rejects it on submit. Mirror your numeric bounds in the schema (e.g. z.number().min(0).max(150)).
currency
Monetary input. The stored value is a plain number (or undefined when empty). The configured currency's symbol renders as a prefix addon; a numeric stepper renders on the trailing edge.
| Prop | Type | Default | Notes |
|---|---|---|---|
type | "currency" | — | Required discriminator. |
currency | string | "EUR" | ISO 4217 currency code (EUR, USD, BRL, …). Drives the symbol prefix. Per-field, because one screen may show several currencies. |
min | number | — | Lower bound for the stepper. |
max | number | — | Upper bound for the stepper. |
step | number | 0.01 | Increment for the arrow keys / stepper. Defaults to one cent; set step: 1 for whole-unit amounts. |
The symbol is derived from the app locale plus the currency code via Intl.NumberFormat with currencyDisplay: "narrowSymbol", so EUR → €, USD → $, BRL → R$. Unknown codes fall back to the raw ISO string.
const schema = z.object({
eur_price: z.number().min(0),
usd_price: z.number().min(0),
brl_price: z.number().min(0),
});
const fields: FormFields<typeof schema> = {
eur_price: {
type: "currency",
label: "Price (EUR)",
description: "Default currency. Grouping/decimal follow the app locale.",
placeholder: "0.00",
colSpan: {base: 12, md: 4},
},
usd_price: {
type: "currency",
label: "Price (USD)",
description: "Override the currency code; locale still comes from the app.",
placeholder: "0.00",
currency: "USD",
colSpan: {base: 12, md: 4},
},
brl_price: {
type: "currency",
label: "Price (BRL)",
description: "Brazilian Real — symbol resolves to R$ via narrow-symbol display.",
placeholder: "0,00",
currency: "BRL",
colSpan: {base: 12, md: 4},
},
};The currency prop sets the symbol and code only. The grouping and decimal separators always come from the app locale, not the currency. A BRL field on a pt-PT app renders R$ with pt-PT separators (1.234,56), not Brazilian ones — change the app locale to change the separators.
Masked input
number and currency do not use a native <input type="number">. They render through MaskedInput, a thin shadcn-styled wrapper around react-imask's IMaskInput. The mask (mask={Number}) owns caret position, paste handling, selection across the thousand-group separator, and rejection of non-numeric characters mid-keystroke — the parts a native number input handles poorly or not at all.
With unmask="typed", the mask emits a real number back to the form (not a formatted string), so the stored value is always numeric.
Two details follow from the Number mask:
- Decimal precision (
scale) is fixed per field type:numberallows up to 6 fractional digits,currencyallows 2. This is baked into the renderer and is not currently a config option. - Either separator is accepted as the decimal: the mask maps both
.and,to the locale's radix, so users can type1.5or1,5regardless of locale and get the same stored number.
Locale-aware formatting
The thousand-group separator and decimal radix for number and currency come from the app locale via the useLocale() hook (from LocaleProvider), resolved by inspecting how Intl.NumberFormat renders a sample number for that locale. For example pt-PT groups with . and uses , as the decimal (1.234,56); en-US is the reverse (1,234.56).
There is no per-field locale prop. Formatting is driven entirely by the app's LocaleProvider, deliberately — a single screen mixing locales is a UX foot-gun. Change the app locale (via the host or localStorage key ogmf.locale in the playground) and every numeric/currency input re-formats in sync. The currency code stays per-field; only the separators are global.
The numeric stepper
number and currency render a NumberStepper — a vertical pair of chevron buttons on the trailing edge — to restore the spinner UX that the move off <input type="number"> removed. Stepping is wired through the useNumericStepper hook, which:
- Increments / decrements by
step(default1fornumber,0.01forcurrency). - Clamps results to
min/max, disabling the up/down button at each boundary. - Rounds to the step's decimal precision, so repeated
0.1steps don't drift to0.30000000000000004.
Keyboard users get the same behaviour through the arrow keys: ↑ increments and ↓ decrements, handled on the input itself. The input owns the tab stop; the stepper buttons are tabIndex={-1} mouse affordances.
The stepper buttons are hidden on touch devices (pointer-coarse). Touch users edit via the decimal keyboard and tap-to-edit; the chevrons only appear for fine pointers (mouse / trackpad). Don't rely on the buttons being present in a touch-only flow — the arrow-key path and direct typing always work.
Async-validation spinner
When a field defines validateAsync, a spinner appears inline inside the input group while the check is in flight. Among the field types on this page, text, textarea, number, and currency all render this inline indicator — a small spinner on the trailing edge of the input (block-end for textarea). It is emitted only while isValidating is true and sits alongside any other addon (the currency symbol, the number stepper).
Async validators are skipped while synchronous validation still fails. If the Zod schema or the field's own validate reports an error, the async check does not fire — so a "check uniqueness" round-trip only runs once the value is otherwise valid. The async result does not stack on top of a failing sync error.
const fields: FormFields<typeof schema> = {
nif: {
type: "text",
label: "NIF",
placeholder: "123456789",
validateAsync: async (value, _all, signal) => {
if (typeof value !== "string" || value.length < 9) return undefined;
const res = await fetch(`/api/nif/${value}`, {signal});
const {taken} = await res.json();
return taken ? "nif_taken" : undefined;
},
validateAsyncDebounceMs: 400,
},
};The async validator returns undefined for valid or a string error code (resolved through the i18n error-map convention). It receives an AbortSignal — pass it to fetch so in-flight checks cancel when the value changes mid-flight or the field unmounts.
Hidden fields are still validated
Any of these fields can be conditionally hidden with a visible predicate. A hidden field keeps its stored value and is still validated on submit — visibility does not skip validation.
If a field can be hidden, its schema branch must accept the value it would hold while hidden — make it .optional() / .nullable(), or model the branch with z.discriminatedUnion. Otherwise a hidden, empty z.string().min(1) (or z.number()) field fails submit-time validation with no visible field to show the error, and the Submit button appears inert.
const schema = z.object({
has_discount: z.boolean(),
discount: z.number().min(0).optional(),
});
const fields: FormFields<typeof schema> = {
has_discount: {type: "checkbox", label: "Apply discount"},
discount: {
type: "currency",
label: "Discount",
visible: (values) => values.has_discount === true,
},
};